From 5d52d3103a85a95b25e0ffb5d685f4b9288a4f59 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 3 Aug 2022 11:51:10 -0400 Subject: [PATCH 01/28] Roll Plugins from 0d6d03a94ed5 to e74c42028d39 (5 revisions) (#108887) --- bin/internal/flutter_plugins.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/flutter_plugins.version b/bin/internal/flutter_plugins.version index 9ec3b0fccc606..e9ac21ce91a3a 100644 --- a/bin/internal/flutter_plugins.version +++ b/bin/internal/flutter_plugins.version @@ -1 +1 @@ -0d6d03a94ed515c8cfae7517587f5b00f2cbfa0a +e74c42028d399116cc50f94ff1b0c0a729f7c6e2 From dbadee003f344e0940308364f1080d82aa8b9b36 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 3 Aug 2022 13:02:07 -0400 Subject: [PATCH 02/28] Roll Flutter Engine from 51296a62d98c to 2c282981d849 (6 revisions) (#108899) --- bin/internal/engine.version | 2 +- bin/internal/fuchsia-linux.version | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 8c8d19fc2bae1..0f44cf3e24190 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -51296a62d98c1e03e1207cedcea0ff9e0d434394 +2c282981d8490f44f6358a1397f6019f9f74709a diff --git a/bin/internal/fuchsia-linux.version b/bin/internal/fuchsia-linux.version index da1bf99e2cd95..fab4268009737 100644 --- a/bin/internal/fuchsia-linux.version +++ b/bin/internal/fuchsia-linux.version @@ -1 +1 @@ -ERGTYC7pfsifuKhgfWttuibiwb2UJRhNVg1Inlkxua4C +TYBmxFHAk2GAhAAwJyXybWL9Zqe40KaFtodBUxHDAs4C From 4d73448b52a4c6a8adf607755db422b456a8b7e3 Mon Sep 17 00:00:00 2001 From: Jia Hao Date: Thu, 4 Aug 2022 01:56:05 +0800 Subject: [PATCH 03/28] [flutter_test] Add flag to send device pointer events to the framework (#108430) --- packages/flutter_test/lib/src/binding.dart | 44 ++++++++++++++- .../flutter_test/lib/src/widget_tester.dart | 8 +++ .../flutter_test/test/live_binding_test.dart | 55 ++++++++++++++++++- 3 files changed, 105 insertions(+), 2 deletions(-) diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 3c74950cfa95a..c1ab7ce180ed0 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -494,9 +494,25 @@ abstract class TestWidgetsFlutterBinding extends BindingBase /// /// When [handlePointerEvent] is called directly, [pointerEventSource] /// is [TestBindingEventSource.device]. + /// + /// This means that pointer events triggered by the [WidgetController] (e.g. + /// via [WidgetController.tap]) will result in actual interactions with the + /// UI, but other pointer events such as those from physical taps will be + /// dropped. See also [shouldPropagateDevicePointerEvents] if this is + /// undesired. TestBindingEventSource get pointerEventSource => _pointerEventSource; TestBindingEventSource _pointerEventSource = TestBindingEventSource.device; + /// Whether pointer events from [TestBindingEventSource.device] will be + /// propagated to the framework, or dropped. + /// + /// Setting this can be useful to interact with the app in some other way + /// besides through the [WidgetController], such as with `adb shell input tap` + /// on Android. + /// + /// See also [pointerEventSource]. + bool shouldPropagateDevicePointerEvents = false; + /// Dispatch an event to the targets found by a hit test on its position, /// and remember its source as [pointerEventSource]. /// @@ -836,6 +852,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase final bool autoUpdateGoldensBeforeTest = autoUpdateGoldenFiles && !isBrowser; final TestExceptionReporter reportTestExceptionBeforeTest = reportTestException; final ErrorWidgetBuilder errorWidgetBuilderBeforeTest = ErrorWidget.builder; + final bool shouldPropagateDevicePointerEventsBeforeTest = shouldPropagateDevicePointerEvents; // run the test await testBody(); @@ -854,6 +871,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase _verifyAutoUpdateGoldensUnset(autoUpdateGoldensBeforeTest && !isBrowser); _verifyReportTestExceptionUnset(reportTestExceptionBeforeTest); _verifyErrorWidgetBuilderUnset(errorWidgetBuilderBeforeTest); + _verifyShouldPropagateDevicePointerEventsUnset(shouldPropagateDevicePointerEventsBeforeTest); _verifyInvariants(); } @@ -943,6 +961,21 @@ abstract class TestWidgetsFlutterBinding extends BindingBase }()); } + void _verifyShouldPropagateDevicePointerEventsUnset(bool valueBeforeTest) { + assert(() { + if (shouldPropagateDevicePointerEvents != valueBeforeTest) { + FlutterError.reportError(FlutterErrorDetails( + exception: FlutterError( + 'The value of shouldPropagateDevicePointerEvents was changed by the test.', + ), + stack: StackTrace.current, + library: 'Flutter test framework', + )); + } + return true; + }()); + } + /// Called by the [testWidgets] function after a test is executed. void postTest() { assert(inTest); @@ -1595,7 +1628,8 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { /// /// Normally, device events are silently dropped. However, if this property is /// set to a non-null value, then the events will be routed to its - /// [HitTestDispatcher.dispatchEvent] method instead. + /// [HitTestDispatcher.dispatchEvent] method instead, unless + /// [shouldPropagateDevicePointerEvents] is true. /// /// Events dispatched by [TestGesture] are not affected by this. HitTestDispatcher? deviceEventDispatcher; @@ -1630,6 +1664,10 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { super.handlePointerEvent(event); break; case TestBindingEventSource.device: + if (shouldPropagateDevicePointerEvents) { + super.handlePointerEvent(event); + break; + } if (deviceEventDispatcher != null) { // The pointer events received with this source has a global position // (see [handlePointerEventForSource]). Transform it to the local @@ -1651,6 +1689,10 @@ class LiveTestWidgetsFlutterBinding extends TestWidgetsFlutterBinding { break; case TestBindingEventSource.device: assert(hitTestResult != null || event is PointerAddedEvent || event is PointerRemovedEvent); + if (shouldPropagateDevicePointerEvents) { + super.dispatchEvent(event, hitTestResult); + break; + } assert(deviceEventDispatcher != null); if (hitTestResult != null) { deviceEventDispatcher!.dispatchEvent(event, hitTestResult); diff --git a/packages/flutter_test/lib/src/widget_tester.dart b/packages/flutter_test/lib/src/widget_tester.dart index 0b2e93f877e89..08e8ef91f4ba2 100644 --- a/packages/flutter_test/lib/src/widget_tester.dart +++ b/packages/flutter_test/lib/src/widget_tester.dart @@ -502,6 +502,10 @@ Future expectLater( /// /// For convenience, instances of this class (such as the one provided by /// `testWidgets`) can be used as the `vsync` for `AnimationController` objects. +/// +/// When the binding is [LiveTestWidgetsFlutterBinding], events from +/// [LiveTestWidgetsFlutterBinding.deviceEventDispatcher] will be handled in +/// [dispatchEvent]. class WidgetTester extends WidgetController implements HitTestDispatcher, TickerProvider { WidgetTester._(super.binding) { if (binding is LiveTestWidgetsFlutterBinding) { @@ -817,6 +821,10 @@ class WidgetTester extends WidgetController implements HitTestDispatcher, Ticker } /// Handler for device events caught by the binding in live test mode. + /// + /// [PointerDownEvent]s received here will only print a diagnostic message + /// showing possible [Finder]s that can be used to interact with the widget at + /// the location of [result]. @override void dispatchEvent(PointerEvent event, HitTestResult result) { if (event is PointerDownEvent) { diff --git a/packages/flutter_test/test/live_binding_test.dart b/packages/flutter_test/test/live_binding_test.dart index ee656e5f5309e..c38bb0cce497d 100644 --- a/packages/flutter_test/test/live_binding_test.dart +++ b/packages/flutter_test/test/live_binding_test.dart @@ -8,7 +8,7 @@ import 'package:flutter_test/flutter_test.dart'; // This file is for testings that require a `LiveTestWidgetsFlutterBinding` void main() { - LiveTestWidgetsFlutterBinding(); + final LiveTestWidgetsFlutterBinding binding = LiveTestWidgetsFlutterBinding(); testWidgets('Input PointerAddedEvent', (WidgetTester tester) async { await tester.pumpWidget(const MaterialApp(home: Text('Test'))); await tester.pump(); @@ -99,4 +99,57 @@ void main() { await expectLater(tester.binding.reassembleApplication(), completes); }, timeout: const Timeout(Duration(seconds: 30))); + + testWidgets('shouldPropagateDevicePointerEvents can override events from ${TestBindingEventSource.device}', (WidgetTester tester) async { + binding.shouldPropagateDevicePointerEvents = true; + + await tester.pumpWidget(_ShowNumTaps()); + + final Offset position = tester.getCenter(find.text('0')); + + // Simulates a real device tap. + // + // `handlePointerEventForSource defaults to sending events using + // TestBindingEventSource.device. This will not be forwarded to the actual + // gesture handlers, unless `shouldPropagateDevicePointerEvents` is true. + binding.handlePointerEventForSource( + PointerDownEvent(position: position), + ); + binding.handlePointerEventForSource( + PointerUpEvent(position: position), + ); + + await tester.pump(); + + expect(find.text('1'), findsOneWidget); + + // Reset the value, otherwise the test will fail when it checks that this + // has not been changed as an invariant. + binding.shouldPropagateDevicePointerEvents = false; + }); +} + +/// A widget that shows the number of times it has been tapped. +class _ShowNumTaps extends StatefulWidget { + @override + _ShowNumTapsState createState() => _ShowNumTapsState(); +} + +class _ShowNumTapsState extends State<_ShowNumTaps> { + int _counter = 0; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + setState(() { + _counter++; + }); + }, + child: Directionality( + textDirection: TextDirection.ltr, + child: Text(_counter.toString()), + ), + ); + } } From 1a4dcd6a6be7e523168dfd92d4dfa5b418e9f8fc Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 3 Aug 2022 14:05:07 -0400 Subject: [PATCH 04/28] Roll Flutter Engine from 2c282981d849 to adba70232a61 (2 revisions) (#108903) --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 0f44cf3e24190..aa2cf59f8148f 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -2c282981d8490f44f6358a1397f6019f9f74709a +adba70232a61ce0ef4d818ce24a0d52fda5bc4ac From c6aeaa305c6ec86ff3148f375f0d9b68f7e1477c Mon Sep 17 00:00:00 2001 From: Hannes Winkler Date: Wed, 3 Aug 2022 20:20:07 +0200 Subject: [PATCH 05/28] fix flutter not finding custom device (#108884) --- packages/flutter_tools/lib/src/doctor.dart | 8 ++++++++ .../test/commands.shard/hermetic/doctor_test.dart | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/flutter_tools/lib/src/doctor.dart b/packages/flutter_tools/lib/src/doctor.dart index 840a203b6b6ce..399aa46d72370 100644 --- a/packages/flutter_tools/lib/src/doctor.dart +++ b/packages/flutter_tools/lib/src/doctor.dart @@ -21,6 +21,7 @@ import 'base/terminal.dart'; import 'base/user_messages.dart'; import 'base/utils.dart'; import 'cache.dart'; +import 'custom_devices/custom_device_workflow.dart'; import 'device.dart'; import 'doctor_validator.dart'; import 'features.dart'; @@ -93,6 +94,10 @@ class _DefaultDoctorValidatorsProvider implements DoctorValidatorsProvider { featureFlags: featureFlags, ); + late final CustomDeviceWorkflow customDeviceWorkflow = CustomDeviceWorkflow( + featureFlags: featureFlags, + ); + @override List get validators { if (_validators != null) { @@ -200,6 +205,9 @@ class _DefaultDoctorValidatorsProvider implements DoctorValidatorsProvider { _workflows!.add(webWorkflow); } + if (customDeviceWorkflow.appliesToHostPlatform) { + _workflows!.add(customDeviceWorkflow); + } } return _workflows!; } diff --git a/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart b/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart index 6a8a9321da40d..172ae7daa136f 100644 --- a/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart +++ b/packages/flutter_tools/test/commands.shard/hermetic/doctor_test.dart @@ -17,6 +17,7 @@ import 'package:flutter_tools/src/base/user_messages.dart'; import 'package:flutter_tools/src/build_info.dart'; import 'package:flutter_tools/src/cache.dart'; import 'package:flutter_tools/src/commands/doctor.dart'; +import 'package:flutter_tools/src/custom_devices/custom_device_workflow.dart'; import 'package:flutter_tools/src/device.dart'; import 'package:flutter_tools/src/doctor.dart'; import 'package:flutter_tools/src/doctor_validator.dart'; @@ -750,6 +751,20 @@ void main() { ProcessManager: () => fakeProcessManager, }); + testUsingContext('CustomDevicesWorkflow is a part of validator workflows if enabled', () async { + final List workflows = DoctorValidatorsProvider.test( + featureFlags: TestFeatureFlags(areCustomDevicesEnabled: true), + platform: FakePlatform(), + ).workflows; + expect( + workflows, + contains(isA()), + ); + }, overrides: { + FileSystem: () => MemoryFileSystem.test(), + ProcessManager: () => fakeProcessManager, + }); + testUsingContext('Fetches tags to get the right version', () async { Cache.disableLocking(); From ec2621f7030421bb74ada1bf01c94a8082be1c76 Mon Sep 17 00:00:00 2001 From: Michael Goderbauer Date: Wed, 3 Aug 2022 11:57:06 -0700 Subject: [PATCH 06/28] Force a11y services to off for complex_layout_semantics_perf test (#108906) --- .../test_driver/semantics_perf_test.dart | 32 ++++++++++++++++++- 1 file changed, 31 insertions(+), 1 deletion(-) diff --git a/dev/benchmarks/complex_layout/test_driver/semantics_perf_test.dart b/dev/benchmarks/complex_layout/test_driver/semantics_perf_test.dart index 9df73ccbdac0a..ecd926b376069 100644 --- a/dev/benchmarks/complex_layout/test_driver/semantics_perf_test.dart +++ b/dev/benchmarks/complex_layout/test_driver/semantics_perf_test.dart @@ -15,6 +15,21 @@ void main() { late FlutterDriver driver; setUpAll(() async { + // Turn off any accessibility services that may be running. The purpose of + // the test is to measure the time it takes to create the initial + // semantics tree in isolation. If accessibility services are on, the + // semantics tree gets generated during the first frame and we can't + // measure it in isolation. + final Process run = await Process.start(_adbPath(), const [ + 'shell', + 'settings', + 'put', + 'secure', + 'enabled_accessibility_services', + 'null', + ]); + await run.exitCode; + driver = await FlutterDriver.connect(printCommunication: true); }); @@ -31,7 +46,13 @@ void main() { await driver.forceGC(); final Timeline timeline = await driver.traceAction(() async { - expect(await driver.setSemantics(true), isTrue); + expect( + await driver.setSemantics(true), + isTrue, + reason: 'Could not toggle semantics to on because semantics were already ' + 'on, but the test needs to toggle semantics to measure the initial ' + 'semantics tree generation in isolation.' + ); }); final Iterable? semanticsEvents = timeline.events?.where((TimelineEvent event) => event.name == 'SEMANTICS'); @@ -45,3 +66,12 @@ void main() { }, timeout: Timeout.none); }); } + +String _adbPath() { + final String? androidHome = Platform.environment['ANDROID_HOME'] ?? Platform.environment['ANDROID_SDK_ROOT']; + if (androidHome == null) { + return 'adb'; + } else { + return p.join(androidHome, 'platform-tools', 'adb'); + } +} From f7c41d0988dec34471e595c7f48cd5f84b4012f9 Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Wed, 3 Aug 2022 12:48:06 -0700 Subject: [PATCH 07/28] Update `equalsIgnoringHashCodes` to take a list of Strings (#108507) --- .../test/material/checkbox_theme_test.dart | 19 +++--- .../flutter/test/material/list_tile_test.dart | 53 +++++++++------- .../test/material/list_tile_theme_test.dart | 34 +++++----- .../test/material/radio_theme_test.dart | 17 +++-- .../flutter/test/widgets/actions_test.dart | 18 ++++-- packages/flutter_test/lib/src/matchers.dart | 63 ++++++++++++------- packages/flutter_test/test/matchers_test.dart | 20 +++++- 7 files changed, 144 insertions(+), 80 deletions(-) diff --git a/packages/flutter/test/material/checkbox_theme_test.dart b/packages/flutter/test/material/checkbox_theme_test.dart index c180dacc15540..b57ce852e9f64 100644 --- a/packages/flutter/test/material/checkbox_theme_test.dart +++ b/packages/flutter/test/material/checkbox_theme_test.dart @@ -64,13 +64,18 @@ void main() { .map((DiagnosticsNode node) => node.toString()) .toList(); - expect(description[0], 'mouseCursor: MaterialStatePropertyAll(SystemMouseCursor(click))'); - expect(description[1], 'fillColor: MaterialStatePropertyAll(Color(0xfffffff0))'); - expect(description[2], 'checkColor: MaterialStatePropertyAll(Color(0xfffffff1))'); - expect(description[3], 'overlayColor: MaterialStatePropertyAll(Color(0xfffffff2))'); - expect(description[4], 'splashRadius: 1.0'); - expect(description[5], 'materialTapTargetSize: MaterialTapTargetSize.shrinkWrap'); - expect(description[6], equalsIgnoringHashCodes('visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)')); + expect( + description, + equalsIgnoringHashCodes([ + 'mouseCursor: MaterialStatePropertyAll(SystemMouseCursor(click))', + 'fillColor: MaterialStatePropertyAll(Color(0xfffffff0))', + 'checkColor: MaterialStatePropertyAll(Color(0xfffffff1))', + 'overlayColor: MaterialStatePropertyAll(Color(0xfffffff2))', + 'splashRadius: 1.0', + 'materialTapTargetSize: MaterialTapTargetSize.shrinkWrap', + 'visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)', + ]), + ); }); testWidgets('Checkbox is themeable', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/list_tile_test.dart b/packages/flutter/test/material/list_tile_test.dart index e54352736245a..8aaca6f211177 100644 --- a/packages/flutter/test/material/list_tile_test.dart +++ b/packages/flutter/test/material/list_tile_test.dart @@ -2276,30 +2276,35 @@ void main() { .map((DiagnosticsNode node) => node.toString()) .toList(); - expect(description[0], 'leading: Text'); - expect(description[1], 'title: Text'); - expect(description[2], 'subtitle: Text'); - expect(description[3], 'trailing: Text'); - expect(description[4], 'isThreeLine: THREE_LINE'); - expect(description[5], 'dense: true'); - expect(description[6], equalsIgnoringHashCodes('visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)')); - expect(description[7], 'shape: RoundedRectangleBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none), BorderRadius.zero)'); - expect(description[8], 'style: ListTileStyle.list'); - expect(description[9], 'selectedColor: Color(0xff0000ff)'); - expect(description[10], 'iconColor: Color(0xff00ff00)'); - expect(description[11], 'textColor: Color(0xffff0000)'); - expect(description[12], 'contentPadding: EdgeInsets.zero'); - expect(description[13], 'enabled: false'); - expect(description[14], 'selected: true'); - expect(description[15], 'focusColor: Color(0xff00ffff)'); - expect(description[16], 'hoverColor: Color(0xff0000ff)'); - expect(description[17], 'autofocus: true'); - expect(description[18], 'tileColor: Color(0xffffff00)'); - expect(description[19], 'selectedTileColor: Color(0xff123456)'); - expect(description[20], 'enableFeedback: false'); - expect(description[21], 'horizontalTitleGap: 4.0'); - expect(description[22], 'minVerticalPadding: 2.0'); - expect(description[23], 'minLeadingWidth: 6.0'); + expect( + description, + equalsIgnoringHashCodes([ + 'leading: Text', + 'title: Text', + 'subtitle: Text', + 'trailing: Text', + 'isThreeLine: THREE_LINE', + 'dense: true', + 'visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)', + 'shape: RoundedRectangleBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none), BorderRadius.zero)', + 'style: ListTileStyle.list', + 'selectedColor: Color(0xff0000ff)', + 'iconColor: Color(0xff00ff00)', + 'textColor: Color(0xffff0000)', + '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', + ]), + ); }); group('Material 2', () { diff --git a/packages/flutter/test/material/list_tile_theme_test.dart b/packages/flutter/test/material/list_tile_theme_test.dart index 5a64101e76fc8..bed87072fdeec 100644 --- a/packages/flutter/test/material/list_tile_theme_test.dart +++ b/packages/flutter/test/material/list_tile_theme_test.dart @@ -107,23 +107,25 @@ void main() { .map((DiagnosticsNode node) => node.toString()) .toList(); - expect(description[0], 'dense: true'); - expect(description[1], 'shape: StadiumBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none))'); - expect(description[2], 'style: drawer'); - expect(description[3], 'selectedColor: Color(0x00000001)'); - expect(description[4], 'iconColor: Color(0x00000002)'); - expect(description[5], 'textColor: Color(0x00000003)'); - expect(description[6], 'contentPadding: EdgeInsets.all(100.0)'); - expect(description[7], 'tileColor: Color(0x00000004)'); - expect(description[8], 'selectedTileColor: Color(0x00000005)'); - expect(description[9], 'horizontalTitleGap: 200.0'); - expect(description[10], 'minVerticalPadding: 300.0'); - expect(description[11], 'minLeadingWidth: 400.0'); - expect(description[12], 'enableFeedback: true'); - expect(description[13], 'mouseCursor: MaterialStateMouseCursor(clickable)'); expect( - description[14], - equalsIgnoringHashCodes('visualDensity: VisualDensity#00000(h: -1.0, v: -1.0)(horizontal: -1.0, vertical: -1.0)'), + description, + equalsIgnoringHashCodes([ + 'dense: true', + 'shape: StadiumBorder(BorderSide(Color(0xff000000), 0.0, BorderStyle.none))', + 'style: drawer', + 'selectedColor: Color(0x00000001)', + 'iconColor: Color(0x00000002)', + 'textColor: Color(0x00000003)', + 'contentPadding: EdgeInsets.all(100.0)', + 'tileColor: Color(0x00000004)', + 'selectedTileColor: Color(0x00000005)', + 'horizontalTitleGap: 200.0', + 'minVerticalPadding: 300.0', + 'minLeadingWidth: 400.0', + 'enableFeedback: true', + 'mouseCursor: MaterialStateMouseCursor(clickable)', + 'visualDensity: VisualDensity#00000(h: -1.0, v: -1.0)(horizontal: -1.0, vertical: -1.0)', + ]), ); }); diff --git a/packages/flutter/test/material/radio_theme_test.dart b/packages/flutter/test/material/radio_theme_test.dart index f3a5f76e501bf..ab53ff8c88941 100644 --- a/packages/flutter/test/material/radio_theme_test.dart +++ b/packages/flutter/test/material/radio_theme_test.dart @@ -61,12 +61,17 @@ void main() { .map((DiagnosticsNode node) => node.toString()) .toList(); - expect(description[0], 'mouseCursor: MaterialStatePropertyAll(SystemMouseCursor(click))'); - expect(description[1], 'fillColor: MaterialStatePropertyAll(Color(0xfffffff0))'); - expect(description[2], 'overlayColor: MaterialStatePropertyAll(Color(0xfffffff1))'); - expect(description[3], 'splashRadius: 1.0'); - expect(description[4], 'materialTapTargetSize: MaterialTapTargetSize.shrinkWrap'); - expect(description[5], equalsIgnoringHashCodes('visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)')); + expect( + description, + equalsIgnoringHashCodes([ + 'mouseCursor: MaterialStatePropertyAll(SystemMouseCursor(click))', + 'fillColor: MaterialStatePropertyAll(Color(0xfffffff0))', + 'overlayColor: MaterialStatePropertyAll(Color(0xfffffff1))', + 'splashRadius: 1.0', + 'materialTapTargetSize: MaterialTapTargetSize.shrinkWrap', + 'visualDensity: VisualDensity#00000(h: 0.0, v: 0.0)', + ]), + ); }); testWidgets('Radio is themeable', (WidgetTester tester) async { diff --git a/packages/flutter/test/widgets/actions_test.dart b/packages/flutter/test/widgets/actions_test.dart index 24d1f6fbb8b24..26de85156ab87 100644 --- a/packages/flutter/test/widgets/actions_test.dart +++ b/packages/flutter/test/widgets/actions_test.dart @@ -1009,8 +1009,13 @@ void main() { .toList(); expect(description.length, equals(2)); - expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); - expect(description[1], equals('actions: {}')); + expect( + description, + equalsIgnoringHashCodes([ + 'dispatcher: ActionDispatcher#00000', + 'actions: {}', + ]), + ); }); testWidgets('Actions implements debugFillProperties', (WidgetTester tester) async { final DiagnosticPropertiesBuilder builder = DiagnosticPropertiesBuilder(); @@ -1032,8 +1037,13 @@ void main() { .toList(); expect(description.length, equals(2)); - expect(description[0], equalsIgnoringHashCodes('dispatcher: ActionDispatcher#00000')); - expect(description[1], equalsIgnoringHashCodes('actions: {TestIntent: TestAction#00000}')); + expect( + description, + equalsIgnoringHashCodes([ + 'dispatcher: ActionDispatcher#00000', + 'actions: {TestIntent: TestAction#00000}', + ]), + ); }); }); diff --git a/packages/flutter_test/lib/src/matchers.dart b/packages/flutter_test/lib/src/matchers.dart index cb9932f894e8a..0d069761aef88 100644 --- a/packages/flutter_test/lib/src/matchers.dart +++ b/packages/flutter_test/lib/src/matchers.dart @@ -292,11 +292,14 @@ Matcher offsetMoreOrLessEquals(Offset value, { double epsilon = precisionErrorTo return _IsWithinDistance(_offsetDistance, value, epsilon); } -/// Asserts that two [String]s are equal after normalizing likely hash codes. +/// Asserts that two [String]s or `Iterable`s are equal after +/// normalizing likely hash codes. /// /// A `#` followed by 5 hexadecimal digits is assumed to be a short hash code /// and is normalized to `#00000`. /// +/// Only [String] or `Iterable` are allowed types for `value`. +/// /// See Also: /// /// * [describeIdentity], a method that generates short descriptions of objects @@ -305,7 +308,8 @@ Matcher offsetMoreOrLessEquals(Offset value, { double epsilon = precisionErrorTo /// [String] based on [Object.hashCode]. /// * [DiagnosticableTree.toStringDeep], a method that returns a [String] /// typically containing multiple hash codes. -Matcher equalsIgnoringHashCodes(String value) { +Matcher equalsIgnoringHashCodes(Object value) { + assert(value is String || value is Iterable, "Only String or Iterable are allowed types for equalsIgnoringHashCodes, it doesn't accept ${value.runtimeType}"); return _EqualsIgnoringHashCodes(value); } @@ -1056,21 +1060,33 @@ class _HasOneLineDescription extends Matcher { } class _EqualsIgnoringHashCodes extends Matcher { - _EqualsIgnoringHashCodes(String v) : _value = _normalize(v); + _EqualsIgnoringHashCodes(Object v) : _value = _normalize(v); - final String _value; + final Object _value; static final Object _mismatchedValueKey = Object(); - static String _normalize(String s) { - return s.replaceAll(RegExp(r'#[0-9a-fA-F]{5}'), '#00000'); + static String _normalizeString(String value) { + return value.replaceAll(RegExp(r'#[\da-fA-F]{5}'), '#00000'); + } + + static Object _normalize(Object value, {bool expected = true}) { + if (value is String) { + return _normalizeString(value); + } + if (value is Iterable) { + return value.map((dynamic item) => _normalizeString(item.toString())); + } + throw ArgumentError('The specified ${expected ? 'expected' : 'comparison'} value for ' + 'equalsIgnoringHashCodes must be a String or an Iterable, ' + 'not a ${value.runtimeType}'); } @override bool matches(dynamic object, Map matchState) { - final String description = _normalize(object as String); - if (_value != description) { - matchState[_mismatchedValueKey] = description; + final Object normalized = _normalize(object as Object, expected: false); + if (!equals(_value).matches(normalized, matchState)) { + matchState[_mismatchedValueKey] = normalized; return false; } return true; @@ -1078,7 +1094,10 @@ class _EqualsIgnoringHashCodes extends Matcher { @override Description describe(Description description) { - return description.add('multi line description equals $_value'); + if (_value is String) { + return description.add('normalized value matches $_value'); + } + return description.add('normalized value matches\n').addDescriptionOf(_value); } @override @@ -1089,14 +1108,14 @@ class _EqualsIgnoringHashCodes extends Matcher { bool verbose, ) { if (matchState.containsKey(_mismatchedValueKey)) { - final String actualValue = matchState[_mismatchedValueKey] as String; + final Object actualValue = matchState[_mismatchedValueKey] as Object; // Leading whitespace is added so that lines in the multiline // description returned by addDescriptionOf are all indented equally // which makes the output easier to read for this case. return mismatchDescription - .add('expected normalized value\n ') + .add('was expected to be normalized value\n') .addDescriptionOf(_value) - .add('\nbut got\n ') + .add('\nbut got\n') .addDescriptionOf(actualValue); } return mismatchDescription; @@ -1164,11 +1183,11 @@ class _HasGoodToStringDeep extends Matcher { for (int i = 0; i < lines.length; ++i) { final String line = lines[i]; if (line.isEmpty) { - issues.add('Line ${i+1} is empty.'); + issues.add('Line ${i + 1} is empty.'); } if (line.trimRight() != line) { - issues.add('Line ${i+1} has trailing whitespace.'); + issues.add('Line ${i + 1} has trailing whitespace.'); } } @@ -1179,11 +1198,11 @@ class _HasGoodToStringDeep extends Matcher { // If a toStringDeep method doesn't properly handle nested values that // contain line breaks it can fail to add the required prefixes to all // lined when toStringDeep is called specifying prefixes. - const String prefixLineOne = 'PREFIX_LINE_ONE____'; + const String prefixLineOne = 'PREFIX_LINE_ONE____'; const String prefixOtherLines = 'PREFIX_OTHER_LINES_'; final List prefixIssues = []; - String descriptionWithPrefixes = - object.toStringDeep(prefixLineOne: prefixLineOne, prefixOtherLines: prefixOtherLines) as String; // ignore: avoid_dynamic_calls + // ignore: avoid_dynamic_calls + String descriptionWithPrefixes = object.toStringDeep(prefixLineOne: prefixLineOne, prefixOtherLines: prefixOtherLines) as String; if (descriptionWithPrefixes.endsWith('\n')) { // Trim off trailing \n as the remaining calculations assume // the description does not end with a trailing \n. @@ -1197,7 +1216,7 @@ class _HasGoodToStringDeep extends Matcher { for (int i = 1; i < linesWithPrefixes.length; ++i) { if (!linesWithPrefixes[i].startsWith(prefixOtherLines)) { - prefixIssues.add('Line ${i+1} does not contain the expected prefix.'); + prefixIssues.add('Line ${i + 1} does not contain the expected prefix.'); } } @@ -1979,9 +1998,9 @@ int _countDifferentPixels(Uint8List imageA, Uint8List imageB) { int delta = 0; for (int i = 0; i < imageA.length; i+=4) { if (imageA[i] != imageB[i] || - imageA[i+1] != imageB[i+1] || - imageA[i+2] != imageB[i+2] || - imageA[i+3] != imageB[i+3]) { + imageA[i + 1] != imageB[i + 1] || + imageA[i + 2] != imageB[i + 2] || + imageA[i + 3] != imageB[i + 3]) { delta++; } } diff --git a/packages/flutter_test/test/matchers_test.dart b/packages/flutter_test/test/matchers_test.dart index 5e6f3d7866211..b4b37b63d3c17 100644 --- a/packages/flutter_test/test/matchers_test.dart +++ b/packages/flutter_test/test/matchers_test.dart @@ -135,7 +135,7 @@ void main() { ); }); - test('normalizeHashCodesEquals', () { + test('equalsIgnoringHashCodes', () { expect('Foo#34219', equalsIgnoringHashCodes('Foo#00000')); expect('Foo#34219', equalsIgnoringHashCodes('Foo#12345')); expect('Foo#34219', equalsIgnoringHashCodes('Foo#abcdf')); @@ -173,6 +173,24 @@ void main() { expect('Foo#', isNot(equalsIgnoringHashCodes('Foo#00000'))); expect('Foo#3421', isNot(equalsIgnoringHashCodes('Foo#00000'))); expect('Foo#342193', isNot(equalsIgnoringHashCodes('Foo#00000'))); + expect(['Foo#a3b4d'], equalsIgnoringHashCodes(['Foo#12345'])); + expect( + ['Foo#a3b4d', 'Foo#12345'], + equalsIgnoringHashCodes(['Foo#00000', 'Foo#00000']), + ); + expect( + ['Foo#a3b4d', 'Bar#12345'], + equalsIgnoringHashCodes(['Foo#00000', 'Bar#00000']), + ); + expect( + ['Foo#a3b4d', 'Bar#12345'], + isNot(equalsIgnoringHashCodes(['Bar#00000', 'Foo#00000'])), + ); + expect(['Foo#a3b4d'], isNot(equalsIgnoringHashCodes(['Foo']))); + expect( + ['Foo#a3b4d'], + isNot(equalsIgnoringHashCodes(['Foo#00000', 'Bar#00000'])), + ); }); test('moreOrLessEquals', () { From 7e8f0e57bfbfc6532de016a33a9f0f0e7a6ec3a4 Mon Sep 17 00:00:00 2001 From: Matej Knopp Date: Wed, 3 Aug 2022 21:51:28 +0200 Subject: [PATCH 08/28] [macOS] Use editing intents from engine (#105407) --- .../flutter/lib/src/services/text_input.dart | 9 + .../default_text_editing_shortcuts.dart | 100 ++++++- .../lib/src/widgets/editable_text.dart | 24 +- .../flutter/test/services/autofill_test.dart | 5 + .../test/services/delta_text_input_test.dart | 5 + .../test/services/text_input_test.dart | 36 +++ .../test/widgets/editable_text_test.dart | 101 ++++++- .../lib/src/event_simulation.dart | 18 +- .../flutter_test/lib/src/test_text_input.dart | 20 ++ .../lib/src/test_text_input_key_handler.dart | 279 ++++++++++++++++++ .../test/test_text_input_test.dart | 21 ++ 11 files changed, 597 insertions(+), 21 deletions(-) create mode 100644 packages/flutter_test/lib/src/test_text_input_key_handler.dart diff --git a/packages/flutter/lib/src/services/text_input.dart b/packages/flutter/lib/src/services/text_input.dart index 8710c8beab8ba..2d6320c630df7 100644 --- a/packages/flutter/lib/src/services/text_input.dart +++ b/packages/flutter/lib/src/services/text_input.dart @@ -1165,6 +1165,11 @@ mixin TextInputClient { /// Requests that the client remove the text placeholder. void removeTextPlaceholder() {} + + /// Performs the specified MacOS-specific selector from the + /// `NSStandardKeyBindingResponding` protocol or user-specified selector + /// from `DefaultKeyBinding.Dict`. + void performSelector(String selectorName) {} } /// An interface to receive focus from the engine. @@ -1819,6 +1824,10 @@ class TextInput { case 'TextInputClient.performAction': _currentConnection!._client.performAction(_toTextInputAction(args[1] as String)); break; + case 'TextInputClient.performSelectors': + final List selectors = (args[1] as List).cast(); + selectors.forEach(_currentConnection!._client.performSelector); + break; case 'TextInputClient.performPrivateCommand': final Map firstArg = args[1] as Map; _currentConnection!._client.performPrivateCommand( diff --git a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart index 2c426848307bb..0ad4c04371689 100644 --- a/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart +++ b/packages/flutter/lib/src/widgets/default_text_editing_shortcuts.dart @@ -6,6 +6,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'actions.dart'; +import 'focus_traversal.dart'; import 'framework.dart'; import 'shortcuts.dart'; import 'text_editing_intents.dart'; @@ -258,6 +259,34 @@ class DefaultTextEditingShortcuts extends StatelessWidget { // The macOS shortcuts uses different word/line modifiers than most other // platforms. static final Map _macShortcuts = { + const SingleActivator(LogicalKeyboardKey.keyX, meta: true): const CopySelectionTextIntent.cut(SelectionChangedCause.keyboard), + const SingleActivator(LogicalKeyboardKey.keyC, meta: true): CopySelectionTextIntent.copy, + const SingleActivator(LogicalKeyboardKey.keyV, meta: true): const PasteTextIntent(SelectionChangedCause.keyboard), + const SingleActivator(LogicalKeyboardKey.keyA, meta: true): const SelectAllTextIntent(SelectionChangedCause.keyboard), + const SingleActivator(LogicalKeyboardKey.keyZ, meta: true): const UndoTextIntent(SelectionChangedCause.keyboard), + const SingleActivator(LogicalKeyboardKey.keyZ, shift: true, meta: true): const RedoTextIntent(SelectionChangedCause.keyboard), + + // On desktop these keys should go to the IME when a field is focused, not to other + // Shortcuts. + if (!kIsWeb) ...{ + const SingleActivator(LogicalKeyboardKey.arrowLeft): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowRight): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowUp): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowDown): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.escape): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.space): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.enter): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.tab): const DoNothingAndStopPropagationTextIntent(), + const SingleActivator(LogicalKeyboardKey.tab, shift: true): const DoNothingAndStopPropagationTextIntent(), + }, + }; + + // There is no complete documentation of iOS shortcuts. + static final Map _iOSShortcuts = { for (final bool pressShift in const [true, false]) ...{ SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift): const DeleteCharacterIntent(forward: false), @@ -296,8 +325,8 @@ class DefaultTextEditingShortcuts extends StatelessWidget { const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true, meta: true): const ExpandSelectionToLineBreakIntent(forward: false), const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true, meta: true): const ExpandSelectionToLineBreakIntent(forward: true), - const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: false), - const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: true): const ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: false), + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true, meta: true): const ExpandSelectionToDocumentBoundaryIntent(forward: false), + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true, meta: true): const ExpandSelectionToDocumentBoundaryIntent(forward: true), const SingleActivator(LogicalKeyboardKey.keyT, control: true): const TransposeCharactersIntent(), @@ -331,9 +360,6 @@ class DefaultTextEditingShortcuts extends StatelessWidget { // * Control + shift? + Z }; - // There is no complete documentation of iOS shortcuts. Use mac shortcuts for - // now. - static final Map _iOSShortcuts = _macShortcuts; // The following key combinations have no effect on text editing on this // platform: @@ -461,3 +487,67 @@ class DefaultTextEditingShortcuts extends StatelessWidget { ); } } + +/// Maps the selector from NSStandardKeyBindingResponding to the Intent if the +/// selector is recognized. +Intent? intentForMacOSSelector(String selectorName) { + const Map selectorToIntent = { + 'deleteBackward:': DeleteCharacterIntent(forward: false), + 'deleteWordBackward:': DeleteToNextWordBoundaryIntent(forward: false), + 'deleteToBeginningOfLine:': DeleteToLineBreakIntent(forward: false), + 'deleteForward:': DeleteCharacterIntent(forward: true), + 'deleteWordForward:': DeleteToNextWordBoundaryIntent(forward: true), + 'deleteToEndOfLine:': DeleteToLineBreakIntent(forward: true), + + 'moveLeft:': ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true), + 'moveRight:': ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + 'moveForward:': ExtendSelectionByCharacterIntent(forward: true, collapseSelection: true), + 'moveBackward:': ExtendSelectionByCharacterIntent(forward: false, collapseSelection: true), + + 'moveUp:': ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: true), + 'moveDown:': ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: true), + + 'moveLeftAndModifySelection:': ExtendSelectionByCharacterIntent(forward: false, collapseSelection: false), + 'moveRightAndModifySelection:': ExtendSelectionByCharacterIntent(forward: true, collapseSelection: false), + 'moveUpAndModifySelection:': ExtendSelectionVerticallyToAdjacentLineIntent(forward: false, collapseSelection: false), + 'moveDownAndModifySelection:': ExtendSelectionVerticallyToAdjacentLineIntent(forward: true, collapseSelection: false), + + 'moveWordLeft:': ExtendSelectionToNextWordBoundaryIntent(forward: false, collapseSelection: true), + 'moveWordRight:': ExtendSelectionToNextWordBoundaryIntent(forward: true, collapseSelection: true), + 'moveToBeginningOfParagraph:': ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), + 'moveToEndOfParagraph:': ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), + + 'moveWordLeftAndModifySelection:': ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: false), + 'moveWordRightAndModifySelection:': ExtendSelectionToNextWordBoundaryOrCaretLocationIntent(forward: true), + 'moveParagraphBackwardAndModifySelection:': ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: false, collapseAtReversal: true), + 'moveParagraphForwardAndModifySelection:': ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: false, collapseAtReversal: true), + + 'moveToLeftEndOfLine:': ExtendSelectionToLineBreakIntent(forward: false, collapseSelection: true), + 'moveToRightEndOfLine:': ExtendSelectionToLineBreakIntent(forward: true, collapseSelection: true), + 'moveToBeginningOfDocument:': ExtendSelectionToDocumentBoundaryIntent(forward: false, collapseSelection: true), + 'moveToEndOfDocument:': ExtendSelectionToDocumentBoundaryIntent(forward: true, collapseSelection: true), + + 'moveToLeftEndOfLineAndModifySelection:': ExpandSelectionToLineBreakIntent(forward: false), + 'moveToRightEndOfLineAndModifySelection:': ExpandSelectionToLineBreakIntent(forward: true), + 'moveToBeginningOfDocumentAndModifySelection:': ExpandSelectionToDocumentBoundaryIntent(forward: false), + 'moveToEndOfDocumentAndModifySelection:': ExpandSelectionToDocumentBoundaryIntent(forward: true), + + 'transpose:': TransposeCharactersIntent(), + + 'scrollToBeginningOfDocument:': ScrollToDocumentBoundaryIntent(forward: false), + 'scrollToEndOfDocument:': ScrollToDocumentBoundaryIntent(forward: true), + + // TODO(knopp): Page Up/Down intents are missing (https://github.com/flutter/flutter/pull/105497) + 'scrollPageUp:': ScrollToDocumentBoundaryIntent(forward: false), + 'scrollPageDown:': ScrollToDocumentBoundaryIntent(forward: true), + 'pageUpAndModifySelection': ExpandSelectionToDocumentBoundaryIntent(forward: false), + 'pageDownAndModifySelection:': ExpandSelectionToDocumentBoundaryIntent(forward: true), + + // Escape key when there's no IME selection popup. + 'cancelOperation:': DismissIntent(), + // Tab when there's no IME selection. + 'insertTab:': NextFocusIntent(), + 'insertBacktab:': PreviousFocusIntent(), + }; + return selectorToIntent[selectorName]; +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 3653cd5d3f5c9..11d45138d1f3a 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -21,6 +21,7 @@ import 'binding.dart'; import 'constants.dart'; import 'debug.dart'; import 'default_selection_style.dart'; +import 'default_text_editing_shortcuts.dart'; import 'focus_manager.dart'; import 'focus_scope.dart'; import 'focus_traversal.dart'; @@ -3227,6 +3228,18 @@ class EditableTextState extends State with AutomaticKeepAliveClien }); } + @override + void performSelector(String selectorName) { + final Intent? intent = intentForMacOSSelector(selectorName); + + if (intent != null) { + final BuildContext? primaryContext = primaryFocus?.context; + if (primaryContext != null) { + Actions.invoke(primaryContext, intent); + } + } + } + @override String get autofillId => 'EditableText-$hashCode'; @@ -4421,7 +4434,16 @@ class _UpdateTextSelectionAction exten } final _TextBoundary textBoundary = getTextBoundariesForIntent(intent); - final TextSelection textBoundarySelection = textBoundary.textEditingValue.selection; + + // "textBoundary's selection is only updated after rebuild; if the text + // is the same, use the selection from state, which is more recent. + // This is necessary on macOS where alt+up sends the moveBackward: + // and moveToBeginningOfParagraph: selectors at the same time. + final TextSelection textBoundarySelection = + textBoundary.textEditingValue.text == state._value.text + ? state._value.selection + : textBoundary.textEditingValue.selection; + if (!textBoundarySelection.isValid) { return null; } diff --git a/packages/flutter/test/services/autofill_test.dart b/packages/flutter/test/services/autofill_test.dart index 366f37040aa8d..4653fa8cdec0e 100644 --- a/packages/flutter/test/services/autofill_test.dart +++ b/packages/flutter/test/services/autofill_test.dart @@ -156,6 +156,11 @@ class FakeAutofillClient implements TextInputClient, AutofillClient { void removeTextPlaceholder() { latestMethodCall = 'removeTextPlaceholder'; } + + @override + void performSelector(String selectorName) { + latestMethodCall = 'performSelector'; + } } class FakeAutofillScope with AutofillScopeMixin implements AutofillScope { diff --git a/packages/flutter/test/services/delta_text_input_test.dart b/packages/flutter/test/services/delta_text_input_test.dart index bdce6138668e3..0c755140f22c7 100644 --- a/packages/flutter/test/services/delta_text_input_test.dart +++ b/packages/flutter/test/services/delta_text_input_test.dart @@ -286,5 +286,10 @@ class FakeDeltaTextInputClient implements DeltaTextInputClient { latestMethodCall = 'showToolbar'; } + @override + void performSelector(String selectorName) { + latestMethodCall = 'performSelector'; + } + TextInputConfiguration get configuration => const TextInputConfiguration(enableDeltaModel: true); } diff --git a/packages/flutter/test/services/text_input_test.dart b/packages/flutter/test/services/text_input_test.dart index 289f38631863b..e958aa32256a9 100644 --- a/packages/flutter/test/services/text_input_test.dart +++ b/packages/flutter/test/services/text_input_test.dart @@ -379,6 +379,35 @@ void main() { expect(client.latestMethodCall, 'connectionClosed'); }); + test('TextInputClient performSelectors method is called', () async { + final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); + const TextInputConfiguration configuration = TextInputConfiguration(); + TextInput.attach(client, configuration); + + expect(client.performedSelectors, isEmpty); + expect(client.latestMethodCall, isEmpty); + + // Send performSelectors message. + final ByteData? messageBytes = const JSONMessageCodec().encodeMessage({ + 'args': [ + 1, + [ + 'selector1', + 'selector2', + ] + ], + 'method': 'TextInputClient.performSelectors', + }); + await ServicesBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/textinput', + messageBytes, + (ByteData? _) {}, + ); + + expect(client.latestMethodCall, 'performSelector'); + expect(client.performedSelectors, ['selector1', 'selector2']); + }); + test('TextInputClient performPrivateCommand method is called', () async { // Assemble a TextInputConnection so we can verify its change in state. final FakeTextInputClient client = FakeTextInputClient(TextEditingValue.empty); @@ -704,6 +733,7 @@ class FakeTextInputClient with TextInputClient { FakeTextInputClient(this.currentTextEditingValue); String latestMethodCall = ''; + final List performedSelectors = []; @override TextEditingValue currentTextEditingValue; @@ -757,4 +787,10 @@ class FakeTextInputClient with TextInputClient { void removeTextPlaceholder() { latestMethodCall = 'removeTextPlaceholder'; } + + @override + void performSelector(String selectorName) { + latestMethodCall = 'performSelector'; + performedSelectors.add(selectorName); + } } diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 51ed207f3b130..c635127569717 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -5870,17 +5870,39 @@ void main() { targetPlatform: defaultTargetPlatform, ); - expect( - selection, - equals( - const TextSelection( - baseOffset: 3, - extentOffset: 0, - affinity: TextAffinity.upstream, - ), - ), - reason: 'on $platform', - ); + switch (defaultTargetPlatform) { + // Extend selection. + case TargetPlatform.android: + case TargetPlatform.fuchsia: + case TargetPlatform.linux: + case TargetPlatform.windows: + expect( + selection, + equals( + const TextSelection( + baseOffset: 3, + extentOffset: 0, + affinity: TextAffinity.upstream, + ), + ), + reason: 'on $platform', + ); + break; + // On macOS/iOS expand selection. + case TargetPlatform.iOS: + case TargetPlatform.macOS: + expect( + selection, + equals( + const TextSelection( + baseOffset: 72, + extentOffset: 0, + ), + ), + reason: 'on $platform', + ); + break; + } // Move to start again. await sendKeys( @@ -12562,6 +12584,63 @@ void main() { variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS }), ); }); + + testWidgets('macOS selectors work', (WidgetTester tester) async { + controller.text = 'test\nline2'; + controller.selection = TextSelection.collapsed(offset: controller.text.length); + + final GlobalKey key = GlobalKey(); + + await tester.pumpWidget(MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: SizedBox( + width: 400, + child: EditableText( + key: key, + maxLines: 10, + controller: controller, + showSelectionHandles: true, + autofocus: true, + focusNode: FocusNode(), + style: Typography.material2018().black.subtitle1!, + cursorColor: Colors.blue, + backgroundCursorColor: Colors.grey, + selectionControls: materialTextSelectionControls, + keyboardType: TextInputType.text, + textAlign: TextAlign.right, + ), + ), + ), + )); + + key.currentState!.performSelector('moveLeft:'); + await tester.pump(); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 9), + ); + + key.currentState!.performSelector('moveToBeginningOfParagraph:'); + await tester.pump(); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 5), + ); + + // These both need to be handled, first moves cursor to the end of previous + // paragraph, second moves to the beginning of paragraph. + key.currentState!.performSelector('moveBackward:'); + key.currentState!.performSelector('moveToBeginningOfParagraph:'); + await tester.pump(); + + expect( + controller.selection, + const TextSelection.collapsed(offset: 0), + ); + }); }); group('magnifier', () { diff --git a/packages/flutter_test/lib/src/event_simulation.dart b/packages/flutter_test/lib/src/event_simulation.dart index 147b274056a14..79853f263c3c2 100644 --- a/packages/flutter_test/lib/src/event_simulation.dart +++ b/packages/flutter_test/lib/src/event_simulation.dart @@ -902,8 +902,13 @@ Future simulateKeyDownEvent( String? platform, PhysicalKeyboardKey? physicalKey, String? character, -}) { - return KeyEventSimulator.simulateKeyDownEvent(key, platform: platform, physicalKey: physicalKey, character: character); +}) async { + final bool handled = await KeyEventSimulator.simulateKeyDownEvent(key, platform: platform, physicalKey: physicalKey, character: character); + final ServicesBinding binding = ServicesBinding.instance; + if (!handled && binding is TestWidgetsFlutterBinding) { + await binding.testTextInput.handleKeyDownEvent(key); + } + return handled; } /// Simulates sending a hardware key up event through the system channel. @@ -929,8 +934,13 @@ Future simulateKeyUpEvent( LogicalKeyboardKey key, { String? platform, PhysicalKeyboardKey? physicalKey, -}) { - return KeyEventSimulator.simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey); +}) async { + final bool handled = await KeyEventSimulator.simulateKeyUpEvent(key, platform: platform, physicalKey: physicalKey); + final ServicesBinding binding = ServicesBinding.instance; + if (!handled && binding is TestWidgetsFlutterBinding) { + await binding.testTextInput.handleKeyUpEvent(key); + } + return handled; } /// Simulates sending a hardware key repeat event through the system channel. diff --git a/packages/flutter_test/lib/src/test_text_input.dart b/packages/flutter_test/lib/src/test_text_input.dart index 1b27c413f256f..55138cc94d4d7 100644 --- a/packages/flutter_test/lib/src/test_text_input.dart +++ b/packages/flutter_test/lib/src/test_text_input.dart @@ -4,11 +4,13 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'binding.dart'; import 'deprecated.dart'; import 'test_async_utils.dart'; +import 'test_text_input_key_handler.dart'; export 'package:flutter/services.dart' show TextEditingValue, TextInputAction; @@ -105,6 +107,9 @@ class TestTextInput { } bool _isVisible = false; + // Platform specific key handler that can process unhandled keyboard events. + TestTextInputKeyHandler? _keyHandler; + /// Resets any internal state of this object. /// /// This method is invoked by the testing framework between tests. It should @@ -131,6 +136,7 @@ class TestTextInput { case 'TextInput.clearClient': _client = null; _isVisible = false; + _keyHandler = null; onCleared?.call(); break; case 'TextInput.setEditingState': @@ -138,9 +144,13 @@ class TestTextInput { break; case 'TextInput.show': _isVisible = true; + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.macOS) { + _keyHandler ??= MacOSTestTextInputKeyHandler(_client ?? -1); + } break; case 'TextInput.hide': _isVisible = false; + _keyHandler = null; break; } } @@ -350,4 +360,14 @@ class TestTextInput { (ByteData? data) { /* response from framework is discarded */ }, ); } + + /// Gives text input chance to respond to unhandled key down event. + Future handleKeyDownEvent(LogicalKeyboardKey key) async { + await _keyHandler?.handleKeyDownEvent(key); + } + + /// Gives text input chance to respond to unhandled key up event. + Future handleKeyUpEvent(LogicalKeyboardKey key) async { + await _keyHandler?.handleKeyUpEvent(key); + } } diff --git a/packages/flutter_test/lib/src/test_text_input_key_handler.dart b/packages/flutter_test/lib/src/test_text_input_key_handler.dart new file mode 100644 index 0000000000000..86e92127f13a8 --- /dev/null +++ b/packages/flutter_test/lib/src/test_text_input_key_handler.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:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import 'binding.dart'; + +/// Processes text input events that were not handled by the framework. +abstract class TestTextInputKeyHandler { + /// Process key down event that was not handled by the framework. + Future handleKeyDownEvent(LogicalKeyboardKey key); + + /// Process key up event that was not handled by the framework. + Future handleKeyUpEvent(LogicalKeyboardKey key); +} + +/// MacOS specific key input handler. This class translates standard macOS text editing shortcuts +/// into appropriate selectors similarly to what NSTextInputContext does in Flutter Engine. +class MacOSTestTextInputKeyHandler extends TestTextInputKeyHandler { + /// Create a new macOS specific text input handler. + MacOSTestTextInputKeyHandler(this.client); + + /// ClientId of TextInput + final int client; + + Future _sendSelectors(List selectors) async { + await TestDefaultBinaryMessengerBinding.instance!.defaultBinaryMessenger + .handlePlatformMessage( + SystemChannels.textInput.name, + SystemChannels.textInput.codec.encodeMethodCall( + MethodCall( + 'TextInputClient.performSelectors', [client, selectors]), + ), + (ByteData? data) {/* response from framework is discarded */}, + ); + } + + // These combinations must match NSStandardKeyBindingResponding. + static final Map> _macOSActivatorToSelectors = + >{ + for (final bool pressShift in const [ + true, + false + ]) ...>{ + SingleActivator(LogicalKeyboardKey.backspace, shift: pressShift): + ['deleteBackward:'], + SingleActivator(LogicalKeyboardKey.backspace, + alt: true, shift: pressShift): ['deleteWordBackward:'], + SingleActivator(LogicalKeyboardKey.backspace, + meta: true, shift: pressShift): ['deleteToBeginningOfLine:'], + SingleActivator(LogicalKeyboardKey.delete, shift: pressShift): [ + 'deleteForward:' + ], + SingleActivator(LogicalKeyboardKey.delete, alt: true, shift: pressShift): + ['deleteWordForward:'], + SingleActivator(LogicalKeyboardKey.delete, meta: true, shift: pressShift): + ['deleteToEndOfLine:'], + }, + const SingleActivator(LogicalKeyboardKey.arrowLeft): ['moveLeft:'], + const SingleActivator(LogicalKeyboardKey.arrowRight): [ + 'moveRight:' + ], + const SingleActivator(LogicalKeyboardKey.arrowUp): ['moveUp:'], + const SingleActivator(LogicalKeyboardKey.arrowDown): ['moveDown:'], + const SingleActivator(LogicalKeyboardKey.arrowLeft, shift: true): [ + 'moveLeftAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.arrowRight, shift: true): [ + 'moveRightAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true): [ + 'moveUpAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true): [ + 'moveDownAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true): [ + 'moveWordLeft:' + ], + const SingleActivator(LogicalKeyboardKey.arrowRight, alt: true): [ + 'moveWordRight:' + ], + const SingleActivator(LogicalKeyboardKey.arrowUp, alt: true): [ + 'moveBackward:', + 'moveToBeginningOfParagraph:' + ], + const SingleActivator(LogicalKeyboardKey.arrowDown, alt: true): [ + 'moveForward:', + 'moveToEndOfParagraph:' + ], + const SingleActivator(LogicalKeyboardKey.arrowLeft, alt: true, shift: true): + ['moveWordLeftAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowRight, + alt: true, shift: true): ['moveWordRightAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowUp, alt: true, shift: true): + ['moveParagraphBackwardAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowDown, alt: true, shift: true): + ['moveParagraphForwardAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowLeft, meta: true): [ + 'moveToLeftEndOfLine:' + ], + const SingleActivator(LogicalKeyboardKey.arrowRight, meta: true): [ + 'moveToRightEndOfLine:' + ], + const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true): [ + 'moveToBeginningOfDocument:' + ], + const SingleActivator(LogicalKeyboardKey.arrowDown, meta: true): [ + 'moveToEndOfDocument:' + ], + const SingleActivator(LogicalKeyboardKey.arrowLeft, + meta: true, + shift: true): ['moveToLeftEndOfLineAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowRight, + meta: true, + shift: true): ['moveToRightEndOfLineAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowUp, meta: true, shift: true): + ['moveToBeginningOfDocumentAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.arrowDown, + meta: true, + shift: true): ['moveToEndOfDocumentAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyA, control: true, shift: true): + ['moveToBeginningOfParagraphAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyA, control: true): [ + 'moveToBeginningOfParagraph:' + ], + const SingleActivator(LogicalKeyboardKey.keyB, control: true, shift: true): + ['moveBackwardAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyB, control: true): [ + 'moveBackward:' + ], + const SingleActivator(LogicalKeyboardKey.keyE, control: true, shift: true): + ['moveToEndOfParagraphAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyE, control: true): [ + 'moveToEndOfParagraph:' + ], + const SingleActivator(LogicalKeyboardKey.keyF, control: true, shift: true): + ['moveForwardAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyF, control: true): [ + 'moveForward:' + ], + const SingleActivator(LogicalKeyboardKey.keyK, control: true): [ + 'deleteToEndOfParagraph' + ], + const SingleActivator(LogicalKeyboardKey.keyL, control: true): [ + 'centerSelectionInVisibleArea' + ], + const SingleActivator(LogicalKeyboardKey.keyN, control: true): [ + 'moveDown:' + ], + const SingleActivator(LogicalKeyboardKey.keyN, control: true, shift: true): + ['moveDownAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyO, control: true): [ + 'insertNewlineIgnoringFieldEditor:' + ], + const SingleActivator(LogicalKeyboardKey.keyP, control: true): [ + 'moveUp:' + ], + const SingleActivator(LogicalKeyboardKey.keyP, control: true, shift: true): + ['moveUpAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyT, control: true): [ + 'transpose:' + ], + const SingleActivator(LogicalKeyboardKey.keyV, control: true): [ + 'pageDown:' + ], + const SingleActivator(LogicalKeyboardKey.keyV, control: true, shift: true): + ['pageDownAndModifySelection:'], + const SingleActivator(LogicalKeyboardKey.keyY, control: true): [ + 'yank:' + ], + const SingleActivator(LogicalKeyboardKey.quoteSingle, control: true): + ['insertSingleQuoteIgnoringSubstitution:'], + const SingleActivator(LogicalKeyboardKey.quote, control: true): [ + 'insertDoubleQuoteIgnoringSubstitution:' + ], + const SingleActivator(LogicalKeyboardKey.home): [ + 'scrollToBeginningOfDocument:' + ], + const SingleActivator(LogicalKeyboardKey.end): [ + 'scrollToEndOfDocument:' + ], + const SingleActivator(LogicalKeyboardKey.home, shift: true): [ + 'moveToBeginningOfDocumentAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.end, shift: true): [ + 'moveToEndOfDocumentAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.pageUp): ['scrollPageUp:'], + const SingleActivator(LogicalKeyboardKey.pageDown): [ + 'scrollPageDown:' + ], + const SingleActivator(LogicalKeyboardKey.pageUp, shift: true): [ + 'pageUpAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.pageDown, shift: true): [ + 'pageDownAndModifySelection:' + ], + const SingleActivator(LogicalKeyboardKey.escape): [ + 'cancelOperation:' + ], + const SingleActivator(LogicalKeyboardKey.enter): ['insertNewline:'], + const SingleActivator(LogicalKeyboardKey.enter, alt: true): [ + 'insertNewlineIgnoringFieldEditor:' + ], + const SingleActivator(LogicalKeyboardKey.enter, control: true): [ + 'insertLineBreak:' + ], + const SingleActivator(LogicalKeyboardKey.tab): ['insertTab:'], + const SingleActivator(LogicalKeyboardKey.tab, alt: true): [ + 'insertTabIgnoringFieldEditor:' + ], + const SingleActivator(LogicalKeyboardKey.tab, shift: true): [ + 'insertBacktab:' + ], + }; + + @override + Future handleKeyDownEvent(LogicalKeyboardKey key) async { + if (key == LogicalKeyboardKey.shift || + key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + _shift = true; + } else if (key == LogicalKeyboardKey.alt || + key == LogicalKeyboardKey.altLeft || + key == LogicalKeyboardKey.altRight) { + _alt = true; + } else if (key == LogicalKeyboardKey.meta || + key == LogicalKeyboardKey.metaLeft || + key == LogicalKeyboardKey.metaRight) { + _meta = true; + } else if (key == LogicalKeyboardKey.control || + key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + _control = true; + } else { + for (final MapEntry> entry + in _macOSActivatorToSelectors.entries) { + final SingleActivator activator = entry.key; + if (activator.triggers.first == key && + activator.shift == _shift && + activator.alt == _alt && + activator.meta == _meta && + activator.control == _control) { + await _sendSelectors(entry.value); + return; + } + } + } + } + + @override + Future handleKeyUpEvent(LogicalKeyboardKey key) async { + if (key == LogicalKeyboardKey.shift || + key == LogicalKeyboardKey.shiftLeft || + key == LogicalKeyboardKey.shiftRight) { + _shift = false; + } else if (key == LogicalKeyboardKey.alt || + key == LogicalKeyboardKey.altLeft || + key == LogicalKeyboardKey.altRight) { + _alt = false; + } else if (key == LogicalKeyboardKey.meta || + key == LogicalKeyboardKey.metaLeft || + key == LogicalKeyboardKey.metaRight) { + _meta = false; + } else if (key == LogicalKeyboardKey.control || + key == LogicalKeyboardKey.controlLeft || + key == LogicalKeyboardKey.controlRight) { + _control = false; + } + } + + bool _shift = false; + bool _alt = false; + bool _meta = false; + bool _control = false; +} diff --git a/packages/flutter_test/test/test_text_input_test.dart b/packages/flutter_test/test/test_text_input_test.dart index 20e2d68322212..b607d7e7c983e 100644 --- a/packages/flutter_test/test/test_text_input_test.dart +++ b/packages/flutter_test/test/test_text_input_test.dart @@ -8,6 +8,7 @@ // Fails with "flutter test --test-randomize-ordering-seed=20210721" @Tags(['no-shuffle']) +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -54,4 +55,24 @@ void main() { throwsA(isA()), ); }); + + testWidgets('selectors are called on macOS', (WidgetTester tester) async { + List? selectorNames; + await SystemChannels.textInput.invokeMethod('TextInput.setClient', [1, {}]); + await SystemChannels.textInput.invokeMethod('TextInput.show'); + SystemChannels.textInput.setMethodCallHandler((MethodCall call) async { + if (call.method == 'TextInputClient.performSelectors') { + selectorNames = (call.arguments as List)[1] as List; + } + }); + await tester.sendKeyDownEvent(LogicalKeyboardKey.altLeft); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp); + await SystemChannels.textInput.invokeMethod('TextInput.clearClient'); + + if (defaultTargetPlatform == TargetPlatform.macOS) { + expect(selectorNames, ['moveBackward:', 'moveToBeginningOfParagraph:']); + } else { + expect(selectorNames, isNull); + } + }, variant: TargetPlatformVariant.all()); } From f7b002343917c12faeedebb3ebd355d62e1ee1d0 Mon Sep 17 00:00:00 2001 From: Qun Cheng <36861262+QuncCccccc@users.noreply.github.com> Date: Wed, 3 Aug 2022 13:47:44 -0700 Subject: [PATCH 09/28] Added `IconButtonTheme` and apply it to `IconButton` in M3 (#108332) * Created IconButtonTheme and apply it to IconButton --- .../lib/icon_button_template.dart | 4 + packages/flutter/lib/material.dart | 1 + .../flutter/lib/src/material/constants.dart | 12 + .../flutter/lib/src/material/icon_button.dart | 130 +++++---- .../lib/src/material/icon_button_theme.dart | 123 +++++++++ .../flutter/lib/src/material/popup_menu.dart | 4 +- .../flutter/lib/src/material/theme_data.dart | 20 +- .../flutter/test/material/app_bar_test.dart | 221 ++++++++++++++- .../test/material/icon_button_test.dart | 247 +++++++++++++++++ .../test/material/icon_button_theme_test.dart | 251 ++++++++++++++++++ .../test/material/popup_menu_test.dart | 13 +- .../test/material/theme_data_test.dart | 5 + 12 files changed, 970 insertions(+), 61 deletions(-) create mode 100644 packages/flutter/lib/src/material/icon_button_theme.dart create mode 100644 packages/flutter/test/material/icon_button_theme_test.dart diff --git a/dev/tools/gen_defaults/lib/icon_button_template.dart b/dev/tools/gen_defaults/lib/icon_button_template.dart index 325c4c59e4c10..0e8a9b5dc8d52 100644 --- a/dev/tools/gen_defaults/lib/icon_button_template.dart +++ b/dev/tools/gen_defaults/lib/icon_button_template.dart @@ -88,6 +88,10 @@ class _${blockName}DefaultsM3 extends ButtonStyle { MaterialStateProperty? get maximumSize => ButtonStyleButton.allOrNull(Size.infinite); + @override + MaterialStateProperty? get iconSize => + ButtonStyleButton.allOrNull(${tokens["md.comp.icon-button.icon.size"]}); + // No default side @override diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index 164dbe31cdd7f..88edac4bb2d70 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -89,6 +89,7 @@ export 'src/material/flutter_logo.dart'; export 'src/material/grid_tile.dart'; export 'src/material/grid_tile_bar.dart'; export 'src/material/icon_button.dart'; +export 'src/material/icon_button_theme.dart'; export 'src/material/icons.dart'; export 'src/material/ink_decoration.dart'; export 'src/material/ink_highlight.dart'; diff --git a/packages/flutter/lib/src/material/constants.dart b/packages/flutter/lib/src/material/constants.dart index c673daf519dda..4696cce72d6a5 100644 --- a/packages/flutter/lib/src/material/constants.dart +++ b/packages/flutter/lib/src/material/constants.dart @@ -4,6 +4,8 @@ import 'package:flutter/painting.dart'; +import 'colors.dart'; + /// The minimum dimension of any interactive region according to Material /// guidelines. /// @@ -47,3 +49,13 @@ 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.light]. This color is used in [IconButton] to detect whether +/// [IconTheme.of(context).color] is the same as the default color of [ThemeData.iconTheme]. +const Color kDefaultIconLightColor = Colors.white; + +/// 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]. +const Color kDefaultIconDarkColor = Colors.black87; diff --git a/packages/flutter/lib/src/material/icon_button.dart b/packages/flutter/lib/src/material/icon_button.dart index df456ed848aa7..a72c91083e544 100644 --- a/packages/flutter/lib/src/material/icon_button.dart +++ b/packages/flutter/lib/src/material/icon_button.dart @@ -14,6 +14,7 @@ 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'; @@ -37,7 +38,9 @@ const double _kMinButtonSize = kMinInteractiveDimension; /// 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. +/// 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 @@ -109,6 +112,12 @@ const double _kMinButtonSize = kMinInteractiveDimension; /// 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. +/// /// {@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 @@ -139,10 +148,10 @@ class IconButton extends StatelessWidget { /// 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. + /// Requires one of its ancestors to be a [Material] widget. This requirement + /// no longer exists if [ThemeData.useMaterial3] is set to true. /// - /// The [iconSize], [padding], [autofocus], and [alignment] arguments must not - /// be null (though they each have default values). + /// [autofocus] argument must not be null (though it has default value). /// /// The [icon] argument must be specified, and is typically either an [Icon] /// or an [ImageIcon]. @@ -150,8 +159,8 @@ class IconButton extends StatelessWidget { super.key, this.iconSize, this.visualDensity, - this.padding = const EdgeInsets.all(8.0), - this.alignment = Alignment.center, + this.padding, + this.alignment, this.splashRadius, this.color, this.focusColor, @@ -164,15 +173,13 @@ class IconButton extends StatelessWidget { this.focusNode, this.autofocus = false, this.tooltip, - this.enableFeedback = true, + this.enableFeedback, this.constraints, this.style, this.isSelected, this.selectedIcon, required this.icon, - }) : assert(padding != null), - assert(alignment != null), - assert(splashRadius == null || splashRadius > 0), + }) : assert(splashRadius == null || splashRadius > 0), assert(autofocus != null), assert(icon != null); @@ -187,6 +194,10 @@ class IconButton extends StatelessWidget { /// 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. + /// + /// 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. @@ -202,12 +213,12 @@ class IconButton extends StatelessWidget { /// The padding around the button's icon. The entire padded icon will react /// to input gestures. /// - /// This property must not be null. It defaults to 8.0 padding on all sides. - final EdgeInsetsGeometry padding; + /// 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 must not be null. It defaults to [Alignment.center]. + /// This property can be null. If null, it defaults to [Alignment.center]. /// /// See also: /// @@ -215,7 +226,7 @@ class IconButton extends StatelessWidget { /// specify an [AlignmentGeometry]. /// * [AlignmentDirectional], like [Alignment] for specifying alignments /// relative to text direction. - final AlignmentGeometry alignment; + final AlignmentGeometry? alignment; /// The splash radius. /// @@ -353,7 +364,7 @@ class IconButton extends StatelessWidget { /// See also: /// /// * [Feedback] for providing platform-specific feedback to certain actions. - final bool enableFeedback; + final bool? enableFeedback; /// Optional size constraints for the button. /// @@ -465,6 +476,7 @@ class IconButton extends StatelessWidget { Size? minimumSize, Size? fixedSize, Size? maximumSize, + double? iconSize, BorderSide? side, OutlinedBorder? shape, EdgeInsetsGeometry? padding, @@ -501,6 +513,7 @@ class IconButton extends StatelessWidget { minimumSize: ButtonStyleButton.allOrNull(minimumSize), fixedSize: ButtonStyleButton.allOrNull(fixedSize), maximumSize: ButtonStyleButton.allOrNull(maximumSize), + iconSize: ButtonStyleButton.allOrNull(iconSize), side: ButtonStyleButton.allOrNull(side), shape: ButtonStyleButton.allOrNull(shape), mouseCursor: mouseCursor, @@ -516,25 +529,6 @@ class IconButton extends StatelessWidget { @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - if (!theme.useMaterial3) { - 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; if (theme.useMaterial3) { final Size? minSize = constraints == null @@ -554,6 +548,7 @@ class IconButton extends StatelessWidget { padding: padding, minimumSize: minSize, maximumSize: maxSize, + iconSize: iconSize, alignment: alignment, enabledMouseCursor: mouseCursor, disabledMouseCursor: mouseCursor, @@ -568,16 +563,11 @@ class IconButton extends StatelessWidget { effectiveIcon = selectedIcon!; } - Widget iconButton = IconTheme.merge( - data: IconThemeData( - size: effectiveIconSize, - ), - child: effectiveIcon, - ); + Widget iconButton = effectiveIcon; if (tooltip != null) { iconButton = Tooltip( message: tooltip, - child: iconButton, + child: effectiveIcon, ); } @@ -591,15 +581,36 @@ class IconButton extends StatelessWidget { ); } + 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: padding, + padding: effectivePadding, child: SizedBox( height: effectiveIconSize, width: effectiveIconSize, child: Align( - alignment: alignment, + alignment: effectiveAlignment, child: IconTheme.merge( data: IconThemeData( size: effectiveIconSize, @@ -628,14 +639,14 @@ class IconButton extends StatelessWidget { canRequestFocus: onPressed != null, onTap: onPressed, mouseCursor: mouseCursor ?? (onPressed == null ? SystemMouseCursors.basic : SystemMouseCursors.click), - enableFeedback: enableFeedback, + 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(padding.horizontal, padding.vertical)) * 0.7, + (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, @@ -762,6 +773,7 @@ class _IconButtonM3 extends ButtonStyleButton { /// * `minimumSize` - Size(40, 40) /// * `fixedSize` - null /// * `maximumSize` - Size.infinite + /// * `iconSize` - 24 /// * `side` - null /// * `shape` - StadiumBorder() /// * `mouseCursor` @@ -778,10 +790,30 @@ class _IconButtonM3 extends ButtonStyleButton { return _IconButtonDefaultsM3(context); } - /// Returns null because [IconButton] doesn't have its component theme. + /// 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) { - return null; + final IconThemeData iconTheme = IconTheme.of(context); + final bool isDark = Theme.of(context).brightness == Brightness.dark; + + bool isIconThemeDefault(Color? color) { + if (isDark) { + return color == kDefaultIconLightColor; + } + return color == kDefaultIconDarkColor; + } + final bool isDefaultColor = isIconThemeDefault(iconTheme.color); + final bool isDefaultSize = iconTheme.size == const IconThemeData.fallback().size; + + final ButtonStyle iconThemeStyle = IconButton.styleFrom( + foregroundColor: isDefaultColor ? null : iconTheme.color, + iconSize: isDefaultSize ? null : iconTheme.size + ); + + return IconButtonTheme.of(context).style?.merge(iconThemeStyle) ?? iconThemeStyle; } } @@ -969,6 +1001,10 @@ class _IconButtonDefaultsM3 extends ButtonStyle { MaterialStateProperty? get maximumSize => ButtonStyleButton.allOrNull(Size.infinite); + @override + MaterialStateProperty? get iconSize => + ButtonStyleButton.allOrNull(24.0); + // No default side @override diff --git a/packages/flutter/lib/src/material/icon_button_theme.dart b/packages/flutter/lib/src/material/icon_button_theme.dart new file mode 100644 index 0000000000000..5480b37c64a7c --- /dev/null +++ b/packages/flutter/lib/src/material/icon_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. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; + +import 'button_style.dart'; +import 'theme.dart'; + +/// 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 +/// [MaterialStateProperty] 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. +/// * [MaterialStateProperty.resolve], "resolve" a material state property +/// to a simple value based on a set of [MaterialState]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 [MaterialStateProperty] + /// 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) { + assert (t != null); + if (a == null && b == null) { + return null; + } + 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('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]. + /// + /// The [data] parameter must not be null. + const IconButtonTheme({ + super.key, + required this.data, + required super.child, + }) : assert(data != null); + + /// The configuration of this theme. + final IconButtonThemeData data; + + /// The closest instance of this class that encloses the given context. + /// + /// 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(); + 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/flutter/lib/src/material/popup_menu.dart b/packages/flutter/lib/src/material/popup_menu.dart index 372ddb277693b..2b4adedb0c6ca 100644 --- a/packages/flutter/lib/src/material/popup_menu.dart +++ b/packages/flutter/lib/src/material/popup_menu.dart @@ -37,7 +37,6 @@ const double _kMenuMinWidth = 2.0 * _kMenuWidthStep; const double _kMenuVerticalPadding = 8.0; const double _kMenuWidthStep = 56.0; const double _kMenuScreenPadding = 8.0; -const double _kDefaultIconSize = 24.0; /// Used to configure how the [PopupMenuButton] positions its popup menu. enum PopupMenuPosition { @@ -1241,7 +1240,6 @@ class PopupMenuButtonState extends State> { @override Widget build(BuildContext context) { - final IconThemeData iconTheme = IconTheme.of(context); final bool enableFeedback = widget.enableFeedback ?? PopupMenuTheme.of(context).enableFeedback ?? true; @@ -1265,7 +1263,7 @@ class PopupMenuButtonState extends State> { icon: widget.icon ?? Icon(Icons.adaptive.more), padding: widget.padding, splashRadius: widget.splashRadius, - iconSize: widget.iconSize ?? iconTheme.size ?? _kDefaultIconSize, + iconSize: widget.iconSize, tooltip: widget.tooltip ?? MaterialLocalizations.of(context).showMenuTooltip, onPressed: widget.enabled ? showButtonMenu : null, enableFeedback: enableFeedback, diff --git a/packages/flutter/lib/src/material/theme_data.dart b/packages/flutter/lib/src/material/theme_data.dart index 29ae6d5e5dcef..9ba7539577f86 100644 --- a/packages/flutter/lib/src/material/theme_data.dart +++ b/packages/flutter/lib/src/material/theme_data.dart @@ -19,6 +19,7 @@ import 'checkbox_theme.dart'; import 'chip_theme.dart'; import 'color_scheme.dart'; import 'colors.dart'; +import 'constants.dart'; import 'data_table_theme.dart'; import 'dialog_theme.dart'; import 'divider_theme.dart'; @@ -26,6 +27,7 @@ import 'drawer_theme.dart'; import 'elevated_button_theme.dart'; import 'expansion_tile_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'; @@ -114,6 +116,7 @@ const Color _kDarkThemeSplashColor = Color(0x40CCCCCC); /// * [OutlinedButton] /// * [TextButton] /// * [ElevatedButton] +/// * [IconButton] /// * The time picker widget ([showTimePicker]) /// * [SnackBar] /// * [Chip] @@ -339,6 +342,7 @@ class ThemeData with Diagnosticable { ElevatedButtonThemeData? elevatedButtonTheme, ExpansionTileThemeData? expansionTileTheme, FloatingActionButtonThemeData? floatingActionButtonTheme, + IconButtonThemeData? iconButtonTheme, ListTileThemeData? listTileTheme, NavigationBarThemeData? navigationBarTheme, NavigationRailThemeData? navigationRailTheme, @@ -535,7 +539,7 @@ class ThemeData with Diagnosticable { } textTheme = defaultTextTheme.merge(textTheme); primaryTextTheme = defaultPrimaryTextTheme.merge(primaryTextTheme); - iconTheme ??= isDark ? const IconThemeData(color: Colors.white) : const IconThemeData(color: Colors.black87); + iconTheme ??= isDark ? const IconThemeData(color: kDefaultIconLightColor) : const IconThemeData(color: kDefaultIconDarkColor); primaryIconTheme ??= primaryIsDark ? const IconThemeData(color: Colors.white) : const IconThemeData(color: Colors.black); // COMPONENT THEMES @@ -554,6 +558,7 @@ class ThemeData with Diagnosticable { drawerTheme ??= const DrawerThemeData(); elevatedButtonTheme ??= const ElevatedButtonThemeData(); floatingActionButtonTheme ??= const FloatingActionButtonThemeData(); + iconButtonTheme ??= const IconButtonThemeData(); listTileTheme ??= const ListTileThemeData(); navigationBarTheme ??= const NavigationBarThemeData(); navigationRailTheme ??= const NavigationRailThemeData(); @@ -645,6 +650,7 @@ class ThemeData with Diagnosticable { elevatedButtonTheme: elevatedButtonTheme, expansionTileTheme: expansionTileTheme, floatingActionButtonTheme: floatingActionButtonTheme, + iconButtonTheme: iconButtonTheme, listTileTheme: listTileTheme, navigationBarTheme: navigationBarTheme, navigationRailTheme: navigationRailTheme, @@ -750,6 +756,7 @@ class ThemeData with Diagnosticable { required this.elevatedButtonTheme, required this.expansionTileTheme, required this.floatingActionButtonTheme, + required this.iconButtonTheme, required this.listTileTheme, required this.navigationBarTheme, required this.navigationRailTheme, @@ -892,6 +899,7 @@ class ThemeData with Diagnosticable { assert(elevatedButtonTheme != null), assert(expansionTileTheme != null), assert(floatingActionButtonTheme != null), + assert(iconButtonTheme != null), assert(listTileTheme != null), assert(navigationBarTheme != null), assert(navigationRailTheme != null), @@ -1444,6 +1452,10 @@ class ThemeData with Diagnosticable { /// [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; @@ -1712,6 +1724,7 @@ class ThemeData with Diagnosticable { ElevatedButtonThemeData? elevatedButtonTheme, ExpansionTileThemeData? expansionTileTheme, FloatingActionButtonThemeData? floatingActionButtonTheme, + IconButtonThemeData? iconButtonTheme, ListTileThemeData? listTileTheme, NavigationBarThemeData? navigationBarTheme, NavigationRailThemeData? navigationRailTheme, @@ -1852,6 +1865,7 @@ class ThemeData with Diagnosticable { elevatedButtonTheme: elevatedButtonTheme ?? this.elevatedButtonTheme, expansionTileTheme: expansionTileTheme ?? this.expansionTileTheme, floatingActionButtonTheme: floatingActionButtonTheme ?? this.floatingActionButtonTheme, + iconButtonTheme: iconButtonTheme ?? this.iconButtonTheme, listTileTheme: listTileTheme ?? this.listTileTheme, navigationBarTheme: navigationBarTheme ?? this.navigationBarTheme, navigationRailTheme: navigationRailTheme ?? this.navigationRailTheme, @@ -2050,6 +2064,7 @@ class ThemeData with Diagnosticable { elevatedButtonTheme: ElevatedButtonThemeData.lerp(a.elevatedButtonTheme, b.elevatedButtonTheme, t)!, expansionTileTheme: ExpansionTileThemeData.lerp(a.expansionTileTheme, b.expansionTileTheme, 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)!, navigationBarTheme: NavigationBarThemeData.lerp(a.navigationBarTheme, b.navigationBarTheme, t)!, navigationRailTheme: NavigationRailThemeData.lerp(a.navigationRailTheme, b.navigationRailTheme, t)!, @@ -2150,6 +2165,7 @@ class ThemeData with Diagnosticable { other.elevatedButtonTheme == elevatedButtonTheme && other.expansionTileTheme == expansionTileTheme && other.floatingActionButtonTheme == floatingActionButtonTheme && + other.iconButtonTheme == iconButtonTheme && other.listTileTheme == listTileTheme && other.navigationBarTheme == navigationBarTheme && other.navigationRailTheme == navigationRailTheme && @@ -2247,6 +2263,7 @@ class ThemeData with Diagnosticable { elevatedButtonTheme, expansionTileTheme, floatingActionButtonTheme, + iconButtonTheme, listTileTheme, navigationBarTheme, navigationRailTheme, @@ -2346,6 +2363,7 @@ class ThemeData with Diagnosticable { properties.add(DiagnosticsProperty('elevatedButtonTheme', elevatedButtonTheme, defaultValue: defaultData.elevatedButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('expansionTileTheme', expansionTileTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('floatingActionButtonTheme', floatingActionButtonTheme, defaultValue: defaultData.floatingActionButtonTheme, level: DiagnosticLevel.debug)); + properties.add(DiagnosticsProperty('iconButtonTheme', iconButtonTheme, defaultValue: defaultData.iconButtonTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('listTileTheme', listTileTheme, defaultValue: defaultData.listTileTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('navigationBarTheme', navigationBarTheme, defaultValue: defaultData.navigationBarTheme, level: DiagnosticLevel.debug)); properties.add(DiagnosticsProperty('navigationRailTheme', navigationRailTheme, defaultValue: defaultData.navigationRailTheme, level: DiagnosticLevel.debug)); diff --git a/packages/flutter/test/material/app_bar_test.dart b/packages/flutter/test/material/app_bar_test.dart index b1ea29cc771c4..620d6478600db 100644 --- a/packages/flutter/test/material/app_bar_test.dart +++ b/packages/flutter/test/material/app_bar_test.dart @@ -54,6 +54,13 @@ ScrollController primaryScrollController(WidgetTester tester) { return PrimaryScrollController.of(tester.element(find.byType(CustomScrollView)))!; } +TextStyle? iconStyle(WidgetTester tester, IconData icon) { + final RichText iconRichText = tester.widget( + find.descendant(of: find.byIcon(icon).first, matching: find.byType(RichText)), + ); + return iconRichText.text.style; +} + double appBarHeight(WidgetTester tester) => tester.getSize(find.byType(AppBar, skipOffstage: false)).height; double appBarTop(WidgetTester tester) => tester.getTopLeft(find.byType(AppBar, skipOffstage: false)).dy; double appBarBottom(WidgetTester tester) => tester.getBottomLeft(find.byType(AppBar, skipOffstage: false)).dy; @@ -544,6 +551,28 @@ void main() { ); }); + testWidgets('AppBar drawer icon has default color', (WidgetTester tester) async { + final ThemeData themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + final bool useMaterial3 = themeData.useMaterial3; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + title: const Text('Howdy!'), + ), + drawer: const Drawer(), + ), + ), + ); + + Color? iconColor() => iconStyle(tester, Icons.menu)?.color; + final Color iconColorM2 = themeData.colorScheme.onPrimary; + final Color iconColorM3 = themeData.colorScheme.onSurfaceVariant; + expect(iconColor(), useMaterial3 ? iconColorM3 : iconColorM2); + }); + testWidgets('AppBar drawer icon is sized by iconTheme', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -562,6 +591,28 @@ void main() { ); }); + testWidgets('AppBar drawer icon is colored by iconTheme', (WidgetTester tester) async { + final ThemeData themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + const Color 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(), + ), + ), + ); + + Color? iconColor() => iconStyle(tester, Icons.menu)?.color; + + expect(iconColor(), color); + }); + testWidgets('AppBar endDrawer icon has default size', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -580,6 +631,28 @@ void main() { ); }); + testWidgets('AppBar endDrawer icon has default color', (WidgetTester tester) async { + final ThemeData themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + final bool useMaterial3 = themeData.useMaterial3; + + await tester.pumpWidget( + MaterialApp( + theme: themeData, + home: Scaffold( + appBar: AppBar( + title: const Text('Howdy!'), + ), + endDrawer: const Drawer(), + ), + ), + ); + + Color? iconColor() => iconStyle(tester, Icons.menu)?.color; + final Color iconColorM2 = themeData.colorScheme.onPrimary; + final Color iconColorM3 = themeData.colorScheme.onSurfaceVariant; + expect(iconColor(), useMaterial3 ? iconColorM3 : iconColorM2); + }); + testWidgets('AppBar endDrawer icon is sized by iconTheme', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -598,6 +671,28 @@ void main() { ); }); + testWidgets('AppBar endDrawer icon is colored by iconTheme', (WidgetTester tester) async { + final ThemeData themeData = ThemeData.from(colorScheme: const ColorScheme.light()); + const Color 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(), + ), + ), + ); + + Color? iconColor() => iconStyle(tester, Icons.menu)?.color; + + expect(iconColor(), color); + }); + testWidgets('leading button extends to edge and is square', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -2744,7 +2839,7 @@ void main() { backgroundColor: backgroundColor, leading: Icon(Icons.add_circle, key: leadingIconKey), title: const Text('title'), - actions: [Icon(Icons.add_circle, key: actionIconKey), const Text('action')], + actions: [Icon(Icons.ac_unit, key: actionIconKey), const Text('action')], ), ), ), @@ -2772,8 +2867,123 @@ void main() { 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); }); + // Regression test for https://github.com/flutter/flutter/issues/107305 + group('Icons are colored correctly by IconTheme and ActionIconTheme in M3', () { + testWidgets('Icons and IconButtons are colored by IconTheme in M3', (WidgetTester tester) async { + const Color iconColor = Color(0xff00ff00); + final Key leadingIconKey = UniqueKey(); + final Key actionIconKey = UniqueKey(); + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from( + colorScheme: const ColorScheme.light(), useMaterial3: true), + home: Scaffold( + appBar: AppBar( + iconTheme: const IconThemeData(color: iconColor), + leading: Icon(Icons.add_circle, key: leadingIconKey), + title: const Text('title'), + actions: [ + 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('Action icons and IconButtons are colored by ActionIconTheme - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData.from( + colorScheme: const ColorScheme.light(), + useMaterial3: true, + ); + + const Color 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: [ + 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('The actionIconTheme property overrides iconTheme - M3', (WidgetTester tester) async { + final ThemeData themeData = ThemeData.from( + colorScheme: const ColorScheme.light(), + useMaterial3: true, + ); + + const Color overallIconColor = Color(0xff00ff00); + const Color 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: [ + 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('AppBarTheme.backwardsCompatibility', (WidgetTester tester) async { const Color foregroundColor = Color(0xff00ff00); @@ -2793,7 +3003,7 @@ void main() { foregroundColor: foregroundColor, // only applies if backwardsCompatibility is false leading: Icon(Icons.add_circle, key: leadingIconKey), title: const Text('title'), - actions: [Icon(Icons.add_circle, key: actionIconKey), const Text('action')], + actions: [Icon(Icons.ac_unit, key: actionIconKey), const Text('action')], ), ), ), @@ -2813,6 +3023,13 @@ void main() { 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); }); group('MaterialStateColor scrolledUnder', () { diff --git a/packages/flutter/test/material/icon_button_test.dart b/packages/flutter/test/material/icon_button_test.dart index b8c07e62e01dc..271db0f22ea13 100644 --- a/packages/flutter/test/material/icon_button_test.dart +++ b/packages/flutter/test/material/icon_button_test.dart @@ -376,6 +376,22 @@ void main() { 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(find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align))); + expect(align.alignment, Alignment.center); + }); + testWidgets('test tooltip', (WidgetTester tester) async { await tester.pumpWidget( MaterialApp( @@ -977,6 +993,48 @@ void main() { expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.none); }); + testWidgets('IconTheme opacity test', (WidgetTester tester) async { + final ThemeData 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 ThemeData themeM3 = ThemeData.from(colorScheme: colorScheme, useMaterial3: true); @@ -1013,6 +1071,7 @@ void main() { final Align align = tester.firstWidget(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); @@ -1569,6 +1628,194 @@ void main() { expect(find.byIcon(Icons.account_box), findsNothing); expect(find.byIcon(Icons.ac_unit), findsOneWidget); }); + + 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(), + useMaterial3: true, + ).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(), + useMaterial3: true, + ), + 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(), + useMaterial3: true, + ).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(), + useMaterial3: true, + ), + 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(), + useMaterial3: true, + ), + 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: () {}, + ), + ), + ) + ) + ); + + 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(), + useMaterial3: true, + ).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, useMaterial3: true,), + 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, useMaterial3: true,), + 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 { + + }); + }); } Widget wrap({required Widget child, required bool useMaterial3}) { diff --git a/packages/flutter/test/material/icon_button_theme_test.dart b/packages/flutter/test/material/icon_button_theme_test.dart new file mode 100644 index 0000000000000..44fd8a6d7e228 --- /dev/null +++ b/packages/flutter/test/material/icon_button_theme_test.dart @@ -0,0 +1,251 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('Passing no IconButtonTheme returns defaults', (WidgetTester tester) async { + const ColorScheme colorScheme = ColorScheme.light(); + await tester.pumpWidget( + MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme, useMaterial3: true), + 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(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, null); + expect(material.shape, const StadiumBorder()); + expect(material.textStyle, null); + expect(material.type, MaterialType.button); + + final Align align = tester.firstWidget(find.ancestor(of: find.byIcon(Icons.ac_unit), matching: find.byType(Align))); + expect(align.alignment, Alignment.center); + }); + + group('[Theme, IconTheme, IconButton style overrides]', () { + const Color foregroundColor = Color(0xff000001); + const Color disabledForegroundColor = Color(0xff000002); + const Color backgroundColor = Color(0xff000003); + const Color shadowColor = Color(0xff000004); + const double elevation = 3; + const EdgeInsets padding = EdgeInsets.all(3); + const Size minimumSize = Size(200, 200); + const BorderSide side = BorderSide(color: Colors.green, width: 2); + const OutlinedBorder shape = RoundedRectangleBorder(side: side, borderRadius: BorderRadius.all(Radius.circular(2))); + const MouseCursor enabledMouseCursor = SystemMouseCursors.text; + const MouseCursor disabledMouseCursor = SystemMouseCursors.grab; + const MaterialTapTargetSize tapTargetSize = MaterialTapTargetSize.shrinkWrap; + const Duration animationDuration = Duration(milliseconds: 25); + const bool enableFeedback = false; + const AlignmentGeometry alignment = Alignment.centerLeft; + + final ButtonStyle style = 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(), useMaterial3: true).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 Set enabled = {}; + const Set disabled = { MaterialState.disabled }; + const Set hovered = { MaterialState.hovered }; + const Set focused = { MaterialState.focused }; + const Set pressed = { MaterialState.pressed }; + + void checkButton(WidgetTester tester) { + final Material material = tester.widget(findMaterial); + final InkWell inkWell = tester.widget(findInkWell); + expect(material.textStyle, null); + expect(material.color, backgroundColor); + expect(material.shadowColor, shadowColor); + expect(material.elevation, elevation); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor, enabled), enabledMouseCursor); + expect(MaterialStateProperty.resolveAs(inkWell.mouseCursor, disabled), disabledMouseCursor); + expect(inkWell.overlayColor!.resolve(hovered), foregroundColor.withOpacity(0.08)); + expect(inkWell.overlayColor!.resolve(focused), foregroundColor.withOpacity(0.08)); + expect(inkWell.overlayColor!.resolve(pressed), foregroundColor.withOpacity(0.12)); + expect(inkWell.enableFeedback, enableFeedback); + expect(material.borderRadius, null); + expect(material.shape, shape); + expect(material.animationDuration, animationDuration); + expect(tester.getSize(find.byType(IconButton)), const Size(200, 200)); + final Align align = tester.firstWidget(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 = ColorScheme.light(); + const Color shadowColor = Color(0xff000001); + const Color overriddenColor = Color(0xff000002); + + Widget buildFrame({ Color? overallShadowColor, Color? themeShadowColor, Color? shadowColor }) { + return MaterialApp( + theme: ThemeData.from(colorScheme: colorScheme, useMaterial3: true).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(buttonMaterialFinder); + expect(material.shadowColor, null); //default + + await tester.pumpWidget(buildFrame(overallShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, null); + + await tester.pumpWidget(buildFrame(themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(overallShadowColor: overriddenColor, themeShadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + + await tester.pumpWidget(buildFrame(themeShadowColor: overriddenColor, shadowColor: shadowColor)); + await tester.pumpAndSettle(); // theme animation + material = tester.widget(buttonMaterialFinder); + expect(material.shadowColor, shadowColor); + }); +} diff --git a/packages/flutter/test/material/popup_menu_test.dart b/packages/flutter/test/material/popup_menu_test.dart index cfac72adc31bc..80e1b86d48d43 100644 --- a/packages/flutter/test/material/popup_menu_test.dart +++ b/packages/flutter/test/material/popup_menu_test.dart @@ -2511,10 +2511,10 @@ void main() { } await buildFrame(); - expect(tester.widget(find.byType(IconButton)).iconSize, 24); + expect(tester.getSize(find.byIcon(Icons.adaptive.more)), const Size(24, 24)); await buildFrame(iconSize: 50); - expect(tester.widget(find.byType(IconButton)).iconSize, 50); + expect(tester.getSize(find.byIcon(Icons.adaptive.more)), const Size(50, 50)); }); testWidgets('does not crash in small overlay', (WidgetTester tester) async { @@ -2867,23 +2867,20 @@ void main() { // Popup menu with default icon size. await tester.pumpWidget(buildPopupMenu()); - IconButton iconButton = tester.widget(find.widgetWithIcon(IconButton, Icons.more_vert)); // Default PopupMenuButton icon size is 24.0. - expect(iconButton.iconSize, 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(); - iconButton = tester.widget(find.widgetWithIcon(IconButton, Icons.more_vert)); // PopupMenuButton icon inherits IconTheme's size. - expect(iconButton.iconSize, 30.0); + 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(); - iconButton = tester.widget(find.widgetWithIcon(IconButton, Icons.more_vert)); // PopupMenuButton icon size overrides IconTheme's size. - expect(iconButton.iconSize, 50.0); + expect(tester.getSize(find.byIcon(Icons.more_vert)), const Size(50.0, 50.0)); }); testWidgets('Popup menu clip behavior', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/theme_data_test.dart b/packages/flutter/test/material/theme_data_test.dart index 1162969577e00..0cb5ab4a96da8 100644 --- a/packages/flutter/test/material/theme_data_test.dart +++ b/packages/flutter/test/material/theme_data_test.dart @@ -697,6 +697,7 @@ void main() { elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(backgroundColor: Colors.green)), expansionTileTheme: const ExpansionTileThemeData(backgroundColor: Colors.black), floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Colors.black), + iconButtonTheme: IconButtonThemeData(style: IconButton.styleFrom(foregroundColor: Colors.pink)), listTileTheme: const ListTileThemeData(), navigationBarTheme: const NavigationBarThemeData(backgroundColor: Colors.black), navigationRailTheme: const NavigationRailThemeData(backgroundColor: Colors.black), @@ -809,6 +810,7 @@ void main() { elevatedButtonTheme: const ElevatedButtonThemeData(), expansionTileTheme: const ExpansionTileThemeData(backgroundColor: Colors.black), floatingActionButtonTheme: const FloatingActionButtonThemeData(backgroundColor: Colors.white), + iconButtonTheme: const IconButtonThemeData(), listTileTheme: const ListTileThemeData(), navigationBarTheme: const NavigationBarThemeData(backgroundColor: Colors.white), navigationRailTheme: const NavigationRailThemeData(backgroundColor: Colors.white), @@ -907,6 +909,7 @@ void main() { elevatedButtonTheme: otherTheme.elevatedButtonTheme, expansionTileTheme: otherTheme.expansionTileTheme, floatingActionButtonTheme: otherTheme.floatingActionButtonTheme, + iconButtonTheme: otherTheme.iconButtonTheme, listTileTheme: otherTheme.listTileTheme, navigationBarTheme: otherTheme.navigationBarTheme, navigationRailTheme: otherTheme.navigationRailTheme, @@ -1004,6 +1007,7 @@ void main() { expect(themeDataCopy.elevatedButtonTheme, equals(otherTheme.elevatedButtonTheme)); expect(themeDataCopy.expansionTileTheme, equals(otherTheme.expansionTileTheme)); expect(themeDataCopy.floatingActionButtonTheme, equals(otherTheme.floatingActionButtonTheme)); + expect(themeDataCopy.iconButtonTheme, equals(otherTheme.iconButtonTheme)); expect(themeDataCopy.listTileTheme, equals(otherTheme.listTileTheme)); expect(themeDataCopy.navigationBarTheme, equals(otherTheme.navigationBarTheme)); expect(themeDataCopy.navigationRailTheme, equals(otherTheme.navigationRailTheme)); @@ -1138,6 +1142,7 @@ void main() { 'drawerTheme', 'elevatedButtonTheme', 'floatingActionButtonTheme', + 'iconButtonTheme', 'listTileTheme', 'navigationBarTheme', 'navigationRailTheme', From 04f7ea8459a965a7484af19ae551a86192bad86e Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Wed, 3 Aug 2022 13:58:06 -0700 Subject: [PATCH 10/28] [web] Add onEntrypointLoaded to FlutterLoader. (#108776) --- dev/bots/service_worker_test.dart | 6 + dev/bots/test.dart | 2 + ...ndex_with_flutterjs_entrypoint_loaded.html | 42 ++ dev/md | 0 .../src/web/file_generators/flutter_js.dart | 366 ++++++++++++------ 5 files changed, 299 insertions(+), 117 deletions(-) create mode 100644 dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html create mode 100644 dev/md diff --git a/dev/bots/service_worker_test.dart b/dev/bots/service_worker_test.dart index 2f4d3b0a78e8a..ce148d28233ac 100644 --- a/dev/bots/service_worker_test.dart +++ b/dev/bots/service_worker_test.dart @@ -29,6 +29,7 @@ enum ServiceWorkerTestType { withoutFlutterJs, withFlutterJs, withFlutterJsShort, + withFlutterJsEntrypointLoadedEvent, } // Run a web service worker test as a standalone Dart program. @@ -36,9 +37,11 @@ Future main() async { await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withoutFlutterJs); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJs); await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); + await runWebServiceWorkerTest(headless: false, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withoutFlutterJs); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJs); await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJsShort); + await runWebServiceWorkerTestWithCachingResources(headless: false, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent); await runWebServiceWorkerTestWithBlockedServiceWorkers(headless: false); } @@ -67,6 +70,9 @@ String _testTypeToIndexFile(ServiceWorkerTestType type) { case ServiceWorkerTestType.withFlutterJsShort: indexFile = 'index_with_flutterjs_short.html'; break; + case ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent: + indexFile = 'index_with_flutterjs_entrypoint_loaded.html'; + break; } return indexFile; } diff --git a/dev/bots/test.dart b/dev/bots/test.dart index cbb1bf6ed43c3..b8913fc38e806 100644 --- a/dev/bots/test.dart +++ b/dev/bots/test.dart @@ -1092,9 +1092,11 @@ Future _runWebLongRunningTests() async { () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJs), () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort), + () => runWebServiceWorkerTest(headless: true, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withoutFlutterJs), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJs), () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJsShort), + () => runWebServiceWorkerTestWithCachingResources(headless: true, testType: ServiceWorkerTestType.withFlutterJsEntrypointLoadedEvent), () => runWebServiceWorkerTestWithBlockedServiceWorkers(headless: true), () => _runWebStackTraceTest('profile', 'lib/stack_trace.dart'), () => _runWebStackTraceTest('release', 'lib/stack_trace.dart'), diff --git a/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html b/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html new file mode 100644 index 0000000000000..a364e597103f3 --- /dev/null +++ b/dev/integration_tests/web/web/index_with_flutterjs_entrypoint_loaded.html @@ -0,0 +1,42 @@ + + + + + + + + Codestin Search App + + + + + + + + + + + + + diff --git a/dev/md b/dev/md new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart b/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart index 257cb42d3918a..6c8020a97837f 100644 --- a/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart +++ b/packages/flutter_tools/lib/src/web/file_generators/flutter_js.dart @@ -8,169 +8,301 @@ /// flutter.js should be completely static, so **do not use any parameter or /// environment variable to generate this file**. String generateFlutterJsFile() { - return ''' + return r''' // Copyright 2014 The Flutter 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 script installs service_worker.js to provide PWA functionality to - * application. For more information, see: - * https://developers.google.com/web/fundamentals/primers/service-workers - */ - if (!_flutter) { var _flutter = {}; } _flutter.loader = null; -(function() { +(function () { "use strict"; - class FlutterLoader { - /** - * Creates a FlutterLoader, and initializes its instance methods. - */ - constructor() { - // TODO: Move the below methods to "#private" once supported by all the browsers - // we support. In the meantime, we use the "revealing module" pattern. - - // Watchdog to prevent injecting the main entrypoint multiple times. - this._scriptLoaded = null; - - // Resolver for the pending promise returned by loadEntrypoint. - this._didCreateEngineInitializerResolve = null; - - // Called by Flutter web. - // Bound to `this` now, so "this" is preserved across JS <-> Flutter jumps. - this.didCreateEngineInitializer = this._didCreateEngineInitializer.bind(this); + /** + * Wraps `promise` in a timeout of the given `duration` in ms. + * + * Resolves/rejects with whatever the original `promises` does, or rejects + * if `promise` takes longer to complete than `duration`. In that case, + * `debugName` is used to compose a legible error message. + * + * If `duration` is < 0, the original `promise` is returned unchanged. + * @param {Promise} promise + * @param {number} duration + * @param {string} debugName + * @returns {Promise} a wrapped promise. + */ + async function timeout(promise, duration, debugName) { + if (duration < 0) { + return promise; } + let timeoutId; + const _clock = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error( + `${debugName} took more than ${duration}ms to resolve. Moving on.`, + { + cause: timeout, + } + ) + ); + }, duration); + }); + return Promise.race([promise, _clock]).finally(() => { + clearTimeout(timeoutId); + }); + } + + /** + * Handles loading/reloading Flutter's service worker, if configured. + * + * @see: https://developers.google.com/web/fundamentals/primers/service-workers + */ + class FlutterServiceWorkerLoader { /** - * Initializes the main.dart.js with/without serviceWorker. - * @param {*} options - * @returns a Promise that will eventually resolve with an EngineInitializer, - * or will be rejected with the error caused by the loader. + * Returns a Promise that resolves when the latest Flutter service worker, + * configured by `settings` has been loaded and activated. + * + * Otherwise, the promise is rejected with an error message. + * @param {*} settings Service worker settings + * @returns {Promise} that resolves when the latest serviceWorker is ready. */ - loadEntrypoint(options) { + loadServiceWorker(settings) { + if (!("serviceWorker" in navigator) || settings == null) { + // In the future, settings = null -> uninstall service worker? + return Promise.reject( + new Error("Service worker not supported (or configured).") + ); + } const { - entrypointUrl = "main.dart.js", - serviceWorker, - } = (options || {}); - return this._loadWithServiceWorker(entrypointUrl, serviceWorker); + serviceWorkerVersion, + serviceWorkerUrl = "flutter_service_worker.js?v=" + + serviceWorkerVersion, + timeoutMillis = 4000, + } = settings; + + const serviceWorkerActivation = navigator.serviceWorker + .register(serviceWorkerUrl) + .then(this._getNewServiceWorker) + .then(this._waitForServiceWorkerActivation); + + // Timeout race promise + return timeout( + serviceWorkerActivation, + timeoutMillis, + "prepareServiceWorker" + ); } /** - * Resolves the promise created by loadEntrypoint. - * Called by Flutter through the public `didCreateEngineInitializer` method, - * which is bound to the correct instance of the FlutterLoader on the page. - * @param {*} engineInitializer + * Returns the latest service worker for the given `serviceWorkerRegistrationPromise`. + * + * This might return the current service worker, if there's no new service worker + * awaiting to be installed/updated. + * + * @param {Promise} serviceWorkerRegistrationPromise + * @returns {Promise} */ - _didCreateEngineInitializer(engineInitializer) { - if (typeof this._didCreateEngineInitializerResolve != "function") { - console.warn("Do not call didCreateEngineInitializer by hand. Start with loadEntrypoint instead."); - } - this._didCreateEngineInitializerResolve(engineInitializer); - // Remove the public method after it's done, so Flutter Web can hot restart. - delete this.didCreateEngineInitializer; - } + async _getNewServiceWorker(serviceWorkerRegistrationPromise) { + const reg = await serviceWorkerRegistrationPromise; - _loadEntrypoint(entrypointUrl) { - if (!this._scriptLoaded) { - console.debug("Injecting