diff --git a/.github/workflows/scorecards-analysis.yml b/.github/workflows/scorecards-analysis.yml index 563e90a3424bc..a629695cea7e4 100644 --- a/.github/workflows/scorecards-analysis.yml +++ b/.github/workflows/scorecards-analysis.yml @@ -49,6 +49,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@0c670bbf0414f39666df6ce8e718ec5662c21e03 + uses: github/codeql-action/upload-sarif@2ca79b6fa8d3ec278944088b4aa5f46912db5d63 with: sarif_file: results.sarif diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 8c8d19fc2bae1..5c60ccd0213b0 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -51296a62d98c1e03e1207cedcea0ff9e0d434394 +e32e0d217b96ff985699e1b41a18da3c8e485c2b 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 diff --git a/bin/internal/fuchsia-linux.version b/bin/internal/fuchsia-linux.version index da1bf99e2cd95..315989b8e3096 100644 --- a/bin/internal/fuchsia-linux.version +++ b/bin/internal/fuchsia-linux.version @@ -1 +1 @@ -ERGTYC7pfsifuKhgfWttuibiwb2UJRhNVg1Inlkxua4C +OsNl2rRU2Ke1LGMaUiXwlLoCjQWJKyTR6NIFLJRaOasC 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'); + } +} diff --git a/dev/benchmarks/microbenchmarks/pubspec.yaml b/dev/benchmarks/microbenchmarks/pubspec.yaml index dbeb98a961064..67e6cfdfee266 100644 --- a/dev/benchmarks/microbenchmarks/pubspec.yaml +++ b/dev/benchmarks/microbenchmarks/pubspec.yaml @@ -30,7 +30,7 @@ dependencies: file: 6.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" frontend_server_client: 2.1.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" glob: 2.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" - http: 0.13.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + http: 0.13.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" http_multi_server: 3.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" http_parser: 4.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" intl: 0.17.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" @@ -136,4 +136,4 @@ flutter: - packages/flutter_gallery_assets/people/square/stella.png - packages/flutter_gallery_assets/people/square/trevor.png -# PUBSPEC CHECKSUM: 0cc9 +# PUBSPEC CHECKSUM: 34ca diff --git a/dev/benchmarks/multiple_flutters/module/pubspec.yaml b/dev/benchmarks/multiple_flutters/module/pubspec.yaml index b5e893b968870..0f7289382655a 100644 --- a/dev/benchmarks/multiple_flutters/module/pubspec.yaml +++ b/dev/benchmarks/multiple_flutters/module/pubspec.yaml @@ -19,7 +19,7 @@ dependencies: crypto: 3.0.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" ffi: 2.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" file: 6.1.2 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" - http: 0.13.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + http: 0.13.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" http_parser: 4.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" material_color_utilities: 0.1.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" meta: 1.8.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" @@ -50,4 +50,4 @@ flutter: androidPackage: com.example.multiple_flutters_module iosBundleIdentifier: com.example.multipleFluttersModule -# PUBSPEC CHECKSUM: 74a0 +# PUBSPEC CHECKSUM: 03a1 diff --git a/dev/benchmarks/test_apps/stocks/pubspec.yaml b/dev/benchmarks/test_apps/stocks/pubspec.yaml index c07c242806b92..a40e5bdbf6fe8 100644 --- a/dev/benchmarks/test_apps/stocks/pubspec.yaml +++ b/dev/benchmarks/test_apps/stocks/pubspec.yaml @@ -9,7 +9,7 @@ dependencies: flutter_localizations: sdk: flutter intl: 0.17.0 - http: 0.13.4 + http: 0.13.5 isolate: 2.1.1 async: 2.9.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" @@ -76,4 +76,4 @@ dev_dependencies: flutter: uses-material-design: true -# PUBSPEC CHECKSUM: 66a5 +# PUBSPEC CHECKSUM: cea6 diff --git a/dev/bots/pubspec.yaml b/dev/bots/pubspec.yaml index 29ba4816e60b7..fe78de49ece1b 100644 --- a/dev/bots/pubspec.yaml +++ b/dev/bots/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: glob: 2.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" googleapis: 3.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" googleapis_auth: 1.3.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" - http: 0.13.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" + http: 0.13.5 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" http_multi_server: 3.2.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" io: 1.0.3 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" js: 0.6.4 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" @@ -69,4 +69,4 @@ dependencies: dev_dependencies: test_api: 0.4.12 -# PUBSPEC CHECKSUM: fea5 +# PUBSPEC CHECKSUM: 49a6 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/conductor/core/pubspec.yaml b/dev/conductor/core/pubspec.yaml index 73b0f765ad773..5b1cd8787aec9 100644 --- a/dev/conductor/core/pubspec.yaml +++ b/dev/conductor/core/pubspec.yaml @@ -9,7 +9,7 @@ environment: dependencies: archive: 3.3.1 args: 2.3.1 - http: 0.13.4 + http: 0.13.5 intl: 0.17.0 meta: 1.8.0 path: 1.8.2 @@ -65,4 +65,4 @@ dev_dependencies: web_socket_channel: 2.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" webkit_inspection_protocol: 1.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" -# PUBSPEC CHECKSUM: a0ab +# PUBSPEC CHECKSUM: a2ac diff --git a/dev/devicelab/pubspec.yaml b/dev/devicelab/pubspec.yaml index f0d6b3c7b365b..9887f61325c5b 100644 --- a/dev/devicelab/pubspec.yaml +++ b/dev/devicelab/pubspec.yaml @@ -9,7 +9,7 @@ dependencies: archive: 3.3.1 args: 2.3.1 file: 6.1.2 - http: 0.13.4 + http: 0.13.5 logging: 1.0.2 meta: 1.8.0 metrics_center: 1.0.5 @@ -69,4 +69,4 @@ dev_dependencies: watcher: 1.0.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" web_socket_channel: 2.2.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" -# PUBSPEC CHECKSUM: fea5 +# PUBSPEC CHECKSUM: 49a6 diff --git a/dev/integration_tests/deferred_components_test/lib/main.dart b/dev/integration_tests/deferred_components_test/lib/main.dart index 5495b052eb1fb..428a7afad1204 100644 --- a/dev/integration_tests/deferred_components_test/lib/main.dart +++ b/dev/integration_tests/deferred_components_test/lib/main.dart @@ -62,8 +62,6 @@ class MyHomePageState extends State { // the placeholder text. Future.delayed(const Duration(milliseconds: 750), () { setState(() { - // See https://github.com/dart-lang/sdk/issues/46894 - // ignore: prefer_const_constructors postLoadDisplayWidget = component1.LogoScreen(); }); }); diff --git a/dev/integration_tests/web/lib/stack_trace.dart b/dev/integration_tests/web/lib/stack_trace.dart index 6bc160c9b138a..4290d5ba9d5f9 100644 --- a/dev/integration_tests/web/lib/stack_trace.dart +++ b/dev/integration_tests/web/lib/stack_trace.dart @@ -150,9 +150,8 @@ class StackFrameEquality implements Equality { e1.method == e2.method; } - // TODO(dnfield): This ignore shouldn't be necessary, see https://github.com/dart-lang/sdk/issues/46477 @override - int hash(StackFrame e) { // ignore: avoid_renaming_method_parameters + int hash(StackFrame e) { return Object.hash(e.number, e.packageScheme, e.package, e.packagePath, e.line, e.column, e.className, e.method); } 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/integration_tests/web_e2e_tests/pubspec.yaml b/dev/integration_tests/web_e2e_tests/pubspec.yaml index 93a6ea1bef638..894797259bd20 100644 --- a/dev/integration_tests/web_e2e_tests/pubspec.yaml +++ b/dev/integration_tests/web_e2e_tests/pubspec.yaml @@ -51,7 +51,7 @@ dependencies: dev_dependencies: flutter_goldens: sdk: flutter - http: 0.13.4 + http: 0.13.5 test: 1.21.4 _fe_analyzer_shared: 43.0.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" @@ -84,4 +84,4 @@ dev_dependencies: webkit_inspection_protocol: 1.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" yaml: 3.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" -# PUBSPEC CHECKSUM: 89cf +# PUBSPEC CHECKSUM: f7d0 diff --git a/dev/integration_tests/web_e2e_tests/test_driver/text_editing_integration.dart b/dev/integration_tests/web_e2e_tests/test_driver/text_editing_integration.dart index 3a50606eb000e..b23c1a61ac692 100644 --- a/dev/integration_tests/web_e2e_tests/test_driver/text_editing_integration.dart +++ b/dev/integration_tests/web_e2e_tests/test_driver/text_editing_integration.dart @@ -234,14 +234,11 @@ void main() { KeyboardEvent dispatchKeyboardEvent( EventTarget target, String type, Map args) { - // ignore: implicit_dynamic_function final Object jsKeyboardEvent = js_util.getProperty(window, 'KeyboardEvent') as Object; final List eventArgs = [ type, args, ]; - - // ignore: implicit_dynamic_function final KeyboardEvent event = js_util.callConstructor( jsKeyboardEvent, js_util.jsify(eventArgs) as List) as KeyboardEvent; diff --git a/dev/md b/dev/md new file mode 100644 index 0000000000000..e69de29bb2d1d 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/dev/tools/gen_defaults/lib/text_field_template.dart b/dev/tools/gen_defaults/lib/text_field_template.dart index 8bb06fc206cdc..92da1336fb8c9 100644 --- a/dev/tools/gen_defaults/lib/text_field_template.dart +++ b/dev/tools/gen_defaults/lib/text_field_template.dart @@ -12,5 +12,8 @@ class TextFieldTemplate extends TokenTemplate { // Generated version ${tokens["version"]} TextStyle _m3InputStyle(BuildContext context) => ${textStyle("md.comp.filled-text-field.label-text")}!; + +TextStyle _m3CounterErrorStyle(BuildContext context) => + ${textStyle("md.comp.filled-text-field.supporting-text")}!.copyWith(color:${componentColor('md.comp.filled-text-field.error.supporting-text')}); '''; } diff --git a/dev/tools/gen_keycodes/pubspec.yaml b/dev/tools/gen_keycodes/pubspec.yaml index ad808a3ce19a0..e8543f4e9052b 100644 --- a/dev/tools/gen_keycodes/pubspec.yaml +++ b/dev/tools/gen_keycodes/pubspec.yaml @@ -6,7 +6,7 @@ environment: dependencies: args: 2.3.1 - http: 0.13.4 + http: 0.13.5 meta: 1.8.0 path: 1.8.2 platform: 3.1.0 @@ -57,4 +57,4 @@ dev_dependencies: webkit_inspection_protocol: 1.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" yaml: 3.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" -# PUBSPEC CHECKSUM: 2cf2 +# PUBSPEC CHECKSUM: dbf3 diff --git a/dev/tools/pubspec.yaml b/dev/tools/pubspec.yaml index 4b6396152bbe2..796dbd8d9c723 100644 --- a/dev/tools/pubspec.yaml +++ b/dev/tools/pubspec.yaml @@ -7,7 +7,7 @@ environment: dependencies: archive: 3.3.1 args: 2.3.1 - http: 0.13.4 + http: 0.13.5 intl: 0.17.0 meta: 1.8.0 path: 1.8.2 @@ -61,4 +61,4 @@ dev_dependencies: webkit_inspection_protocol: 1.1.0 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" yaml: 3.1.1 # THIS LINE IS AUTOGENERATED - TO UPDATE USE "flutter update-packages --force-upgrade" -# PUBSPEC CHECKSUM: 7e0a +# PUBSPEC CHECKSUM: 620b 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/foundation/assertions.dart b/packages/flutter/lib/src/foundation/assertions.dart index 223d80704366c..a2bdae4e2cd79 100644 --- a/packages/flutter/lib/src/foundation/assertions.dart +++ b/packages/flutter/lib/src/foundation/assertions.dart @@ -670,7 +670,7 @@ class FlutterErrorDetails with Diagnosticable { super.debugFillProperties(properties); final DiagnosticsNode verb = ErrorDescription('thrown${ context != null ? ErrorDescription(" $context") : ""}'); final Diagnosticable? diagnosticable = _exceptionToDiagnosticable(); - if (exception is NullThrownError) { // ignore: deprecated_member_use + if (exception is NullThrownError) { properties.add(ErrorDescription('The null value was $verb.')); } else if (exception is num) { properties.add(ErrorDescription('The number $exception was $verb.')); diff --git a/packages/flutter/lib/src/gestures/long_press.dart b/packages/flutter/lib/src/gestures/long_press.dart index 73b9ff78a0787..414c2dcebba27 100644 --- a/packages/flutter/lib/src/gestures/long_press.dart +++ b/packages/flutter/lib/src/gestures/long_press.dart @@ -250,8 +250,6 @@ class LongPressGestureRecognizer extends PrimaryPointerGestureRecognizer { /// {@macro flutter.gestures.GestureRecognizer.supportedDevices} LongPressGestureRecognizer({ Duration? duration, - // TODO(goderbauer): remove ignore when https://github.com/dart-lang/linter/issues/3349 is fixed. - // ignore: avoid_init_to_null super.postAcceptSlopTolerance = null, @Deprecated( 'Migrate to supportedDevices. ' 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/shaders/ink_sparkle.frag b/packages/flutter/lib/src/material/shaders/ink_sparkle.frag index 9d9e2717e63fe..b94541d352c22 100644 --- a/packages/flutter/lib/src/material/shaders/ink_sparkle.frag +++ b/packages/flutter/lib/src/material/shaders/ink_sparkle.frag @@ -34,10 +34,6 @@ const float PI_ROTATE_LEFT = PI * -0.0078125; const float ONE_THIRD = 1./3.; const vec2 TURBULENCE_SCALE = vec2(0.8); -float saturate(float x) { - return clamp(x, 0.0, 1.0); -} - float triangle_noise(highp vec2 n) { n = fract(n * vec2(5.3987, 5.4421)); n += dot(n.yx, n.xy + vec2(21.5351, 14.3137)); @@ -62,7 +58,7 @@ float soft_circle(vec2 uv, vec2 xy, float radius, float blur) { float soft_ring(vec2 uv, vec2 xy, float radius, float thickness, float blur) { float circle_outer = soft_circle(uv, xy, radius + thickness, blur); float circle_inner = soft_circle(uv, xy, max(radius - thickness, 0.0), blur); - return saturate(circle_outer - circle_inner); + return clamp(circle_outer - circle_inner, 0.0, 1.0); } float circle_grid(vec2 resolution, vec2 p, vec2 xy, vec2 rotation, float cell_diameter) { @@ -79,7 +75,7 @@ float sparkle(vec2 uv, float t) { s += threshold(n + sin(PI * (t + 0.35)), 0.1, 0.15); s += threshold(n + sin(PI * (t + 0.7)), 0.2, 0.25); s += threshold(n + sin(PI * (t + 1.05)), 0.3, 0.35); - return saturate(s) * 0.55; + return clamp(s, 0.0, 1.0) * 0.55; } float turbulence(vec2 uv) { @@ -88,7 +84,7 @@ float turbulence(vec2 uv) { float g2 = circle_grid(TURBULENCE_SCALE, uv_scale, u_circle2, u_rotation2, 0.2); float g3 = circle_grid(TURBULENCE_SCALE, uv_scale, u_circle3, u_rotation3, 0.275); float v = (g1 * g1 + g2 - g3) * 0.5; - return saturate(0.45 + 0.8 * v); + return clamp(0.45 + 0.8 * v, 0.0, 1.0); } void main() { diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index ef4f3ce6ed36c..ccc4edc7288b9 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -939,7 +939,7 @@ class _TextFieldState extends State with RestorationMixin implements return effectiveDecoration.copyWith( errorText: effectiveDecoration.errorText ?? '', counterStyle: effectiveDecoration.errorStyle - ?? themeData.textTheme.caption!.copyWith(color: themeData.errorColor), + ?? (themeData.useMaterial3 ? _m3CounterErrorStyle(context): _m2CounterErrorStyle(context)), counterText: counterText, semanticCounterText: semanticCounterText, ); @@ -1401,6 +1401,9 @@ class _TextFieldState extends State with RestorationMixin implements } } +TextStyle _m2CounterErrorStyle(BuildContext context) => + Theme.of(context).textTheme.caption!.copyWith(color: Theme.of(context).errorColor); + // BEGIN GENERATED TOKEN PROPERTIES - TextField // Do not edit by hand. The code between the "BEGIN GENERATED" and @@ -1414,4 +1417,7 @@ class _TextFieldState extends State with RestorationMixin implements TextStyle _m3InputStyle(BuildContext context) => Theme.of(context).textTheme.bodyLarge!; +TextStyle _m3CounterErrorStyle(BuildContext context) => + Theme.of(context).textTheme.bodySmall!.copyWith(color:Theme.of(context).colorScheme.error); + // END GENERATED TOKEN PROPERTIES - TextField 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/lib/src/rendering/editable.dart b/packages/flutter/lib/src/rendering/editable.dart index 6bba4a932663d..51e67ecf9a801 100644 --- a/packages/flutter/lib/src/rendering/editable.dart +++ b/packages/flutter/lib/src/rendering/editable.dart @@ -589,6 +589,7 @@ class RenderEditable extends RenderBox with RelayoutWhenSystemFontsChangeMixin, return; } _obscureText = value; + _cachedAttributedValue = null; markNeedsSemanticsUpdate(); } 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/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 55b40aad44f24..041de2631fd5b 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -781,7 +781,7 @@ abstract class StatefulWidget extends Widget { /// [State] objects. @protected @factory - State createState(); // ignore: no_logic_in_create_state, this is the original sin + State createState(); } /// Tracks the lifecycle of [State] objects when asserts are enabled. diff --git a/packages/flutter/lib/src/widgets/implicit_animations.dart b/packages/flutter/lib/src/widgets/implicit_animations.dart index 522ca0590e51c..0c13604fe0fae 100644 --- a/packages/flutter/lib/src/widgets/implicit_animations.dart +++ b/packages/flutter/lib/src/widgets/implicit_animations.dart @@ -297,7 +297,7 @@ abstract class ImplicitlyAnimatedWidget extends StatefulWidget { final VoidCallback? onEnd; @override - ImplicitlyAnimatedWidgetState createState(); // ignore: no_logic_in_create_state, https://github.com/dart-lang/linter/issues/2345 + ImplicitlyAnimatedWidgetState createState(); @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { diff --git a/packages/flutter/lib/src/widgets/scrollbar.dart b/packages/flutter/lib/src/widgets/scrollbar.dart index 67d28bcf3dace..971d9ee0fe5b4 100644 --- a/packages/flutter/lib/src/widgets/scrollbar.dart +++ b/packages/flutter/lib/src/widgets/scrollbar.dart @@ -283,6 +283,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { _shape = value; notifyListeners(); } + /// The amount of space by which to inset the scrollbar's start and end, as /// well as its side to the nearest edge, in logical pixels. /// @@ -304,7 +305,6 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { notifyListeners(); } - /// The preferred smallest size the scrollbar thumb can shrink to when the total /// scrollable extent is large, the current visible viewport is small, and the /// viewport is not overscrolled. @@ -391,23 +391,129 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { notifyListeners(); } - void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { - assert( - (_isVertical && _isVerticalOrientation(orientation)) || (!_isVertical && !_isVerticalOrientation(orientation)), - 'The given ScrollbarOrientation: $orientation is incompatible with the current AxisDirection: $_lastAxisDirection.' + // - Scrollbar Details + + Rect? _trackRect; + // The full painted length of the track + double get _trackExtent => _lastMetrics!.viewportDimension - _totalTrackMainAxisOffsets; + // The full length of the track that the thumb can travel + double get _traversableTrackExtent => _trackExtent - (2 * mainAxisMargin); + // Track Offsets + // The track is offset by only padding. + double get _totalTrackMainAxisOffsets => _isVertical ? padding.vertical : padding.horizontal; + double get _leadingTrackMainAxisOffset { + switch(_resolvedOrientation) { + case ScrollbarOrientation.left: + case ScrollbarOrientation.right: + return padding.top; + case ScrollbarOrientation.top: + case ScrollbarOrientation.bottom: + return padding.left; + } + } + + Rect? _thumbRect; + // The current scroll position + _leadingThumbMainAxisOffset + late double _thumbOffset; + // The fraction visible in relation to the trversable length of the track. + late double _thumbExtent; + // Thumb Offsets + // The thumb is offset by padding and margins. + double get _leadingThumbMainAxisOffset { + switch(_resolvedOrientation) { + case ScrollbarOrientation.left: + case ScrollbarOrientation.right: + return padding.top + mainAxisMargin; + case ScrollbarOrientation.top: + case ScrollbarOrientation.bottom: + return padding.left + mainAxisMargin; + } + } + void _setThumbExtent() { + // Thumb extent reflects fraction of content visible, as long as this + // isn't less than the absolute minimum size. + // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 + final double fractionVisible = clampDouble( + (_lastMetrics!.extentInside - _totalTrackMainAxisOffsets) + / (_totalContentExtent - _totalTrackMainAxisOffsets), + 0.0, + 1.0, ); + + final double thumbExtent = math.max( + math.min(_traversableTrackExtent, minOverscrollLength), + _traversableTrackExtent * fractionVisible, + ); + + final double fractionOverscrolled = 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; + final double safeMinLength = math.min(minLength, _traversableTrackExtent); + final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) + // Thumb extent is no smaller than minLength if scrolling normally. + ? safeMinLength + // User is overscrolling. Thumb extent can be less than minLength + // but no smaller than minOverscrollLength. We can't use the + // fractionVisible to produce intermediate values between minLength and + // minOverscrollLength when the user is transitioning from regular + // scrolling to overscrolling, so we instead use the percentage of the + // content that is still in the viewport to determine the size of the + // thumb. iOS behavior appears to have the thumb reach its minimum size + // with ~20% of overscroll. We map the percentage of minLength from + // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce + // values for the thumb that range between minLength and the smallest + // possible value, minOverscrollLength. + : safeMinLength * (1.0 - clampDouble(fractionOverscrolled, 0.0, 0.2) / 0.2); + + // The `thumbExtent` should be no greater than `trackSize`, otherwise + // the scrollbar may scroll towards the wrong direction. + _thumbExtent = clampDouble(thumbExtent, newMinLength, _traversableTrackExtent); } - /// Check whether given scrollbar orientation is vertical - bool _isVerticalOrientation(ScrollbarOrientation orientation) => - orientation == ScrollbarOrientation.left - || orientation == ScrollbarOrientation.right; + // - Scrollable Details ScrollMetrics? _lastMetrics; + bool get _lastMetricsAreScrollable => _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; AxisDirection? _lastAxisDirection; - Rect? _thumbRect; - Rect? _trackRect; - late double _thumbOffset; + + bool get _isVertical => _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up; + bool get _isReversed => _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left; + // The amount of scroll distance before and after the current position. + double get _beforeExtent => _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; + double get _afterExtent => _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; + + // The total size of the scrollable content. + double get _totalContentExtent { + return _lastMetrics!.maxScrollExtent + - _lastMetrics!.minScrollExtent + + _lastMetrics!.viewportDimension; + } + + ScrollbarOrientation get _resolvedOrientation { + if (scrollbarOrientation == null) { + if (_isVertical) { + return textDirection == TextDirection.ltr + ? ScrollbarOrientation.right + : ScrollbarOrientation.left; + } + return ScrollbarOrientation.bottom; + } + return scrollbarOrientation!; + } + + void _debugAssertIsValidOrientation(ScrollbarOrientation orientation) { + assert( + () { + bool isVerticalOrientation(ScrollbarOrientation orientation) => + orientation == ScrollbarOrientation.left + || orientation == ScrollbarOrientation.right; + return (_isVertical && isVerticalOrientation(orientation)) + || (!_isVertical && !isVerticalOrientation(orientation)); + }(), + 'The given ScrollbarOrientation: $orientation is incompatible with the ' + 'current AxisDirection: $_lastAxisDirection.' + ); + } + + // - Updating /// Update with new [ScrollMetrics]. If the metrics change, the scrollbar will /// show and redraw itself based on these new metrics. @@ -433,7 +539,6 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { if (!needPaint(oldMetrics) && !needPaint(metrics)) { return; } - notifyListeners(); } @@ -443,6 +548,8 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { radius = nextRadius; } + // - Painting + Paint get _paintThumb { return Paint() ..color = color.withOpacity(color.opacity * fadeoutOpacityAnimation.value); @@ -459,67 +566,50 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { ..color = trackColor.withOpacity(trackColor.opacity * fadeoutOpacityAnimation.value); } - void _paintScrollbar(Canvas canvas, Size size, double thumbExtent, AxisDirection direction) { + void _paintScrollbar(Canvas canvas, Size size) { assert( textDirection != null, 'A TextDirection must be provided before a Scrollbar can be painted.', ); - final ScrollbarOrientation resolvedOrientation; - - if (scrollbarOrientation == null) { - if (_isVertical) { - resolvedOrientation = textDirection == TextDirection.ltr - ? ScrollbarOrientation.right - : ScrollbarOrientation.left; - } else { - resolvedOrientation = ScrollbarOrientation.bottom; - } - } - else { - resolvedOrientation = scrollbarOrientation!; - } - final double x, y; final Size thumbSize, trackSize; final Offset trackOffset, borderStart, borderEnd; - - _debugAssertIsValidOrientation(resolvedOrientation); - - switch(resolvedOrientation) { + _debugAssertIsValidOrientation(_resolvedOrientation); + switch(_resolvedOrientation) { case ScrollbarOrientation.left: - thumbSize = Size(thickness, thumbExtent); + thumbSize = Size(thickness, _thumbExtent); trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); x = crossAxisMargin + padding.left; y = _thumbOffset; - trackOffset = Offset(x - crossAxisMargin, padding.top); + trackOffset = Offset(x - crossAxisMargin, _leadingTrackMainAxisOffset); borderStart = trackOffset + Offset(trackSize.width, 0.0); borderEnd = Offset(trackOffset.dx + trackSize.width, trackOffset.dy + _trackExtent); break; case ScrollbarOrientation.right: - thumbSize = Size(thickness, thumbExtent); + thumbSize = Size(thickness, _thumbExtent); trackSize = Size(thickness + 2 * crossAxisMargin, _trackExtent); x = size.width - thickness - crossAxisMargin - padding.right; y = _thumbOffset; - trackOffset = Offset(x - crossAxisMargin, padding.top); + trackOffset = Offset(x - crossAxisMargin, _leadingTrackMainAxisOffset); borderStart = trackOffset; borderEnd = Offset(trackOffset.dx, trackOffset.dy + _trackExtent); break; case ScrollbarOrientation.top: - thumbSize = Size(thumbExtent, thickness); + thumbSize = Size(_thumbExtent, thickness); trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); x = _thumbOffset; y = crossAxisMargin + padding.top; - trackOffset = Offset(padding.left, y - crossAxisMargin); + trackOffset = Offset(_leadingTrackMainAxisOffset, y - crossAxisMargin); borderStart = trackOffset + Offset(0.0, trackSize.height); borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy + trackSize.height); break; case ScrollbarOrientation.bottom: - thumbSize = Size(thumbExtent, thickness); + thumbSize = Size(_thumbExtent, thickness); trackSize = Size(_trackExtent, thickness + 2 * crossAxisMargin); x = _thumbOffset; y = size.height - thickness - crossAxisMargin - padding.bottom; - trackOffset = Offset(padding.left, y - crossAxisMargin); + trackOffset = Offset(_leadingTrackMainAxisOffset, y - crossAxisMargin); borderStart = trackOffset; borderEnd = Offset(trackOffset.dx + _trackExtent, trackOffset.dy); break; @@ -557,70 +647,33 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { } } - double _thumbExtent() { - // Thumb extent reflects fraction of content visible, as long as this - // isn't less than the absolute minimum size. - // _totalContentExtent >= viewportDimension, so (_totalContentExtent - _mainAxisPadding) > 0 - final double fractionVisible = clampDouble( - (_lastMetrics!.extentInside - _mainAxisPadding) / - (_totalContentExtent - _mainAxisPadding), - 0.0, - 1.0); - - final double thumbExtent = math.max( - math.min(_traversableTrackExtent, minOverscrollLength), - _traversableTrackExtent * fractionVisible, - ); - - final double fractionOverscrolled = 1.0 - _lastMetrics!.extentInside / _lastMetrics!.viewportDimension; - final double safeMinLength = math.min(minLength, _traversableTrackExtent); - final double newMinLength = (_beforeExtent > 0 && _afterExtent > 0) - // Thumb extent is no smaller than minLength if scrolling normally. - ? safeMinLength - // User is overscrolling. Thumb extent can be less than minLength - // but no smaller than minOverscrollLength. We can't use the - // fractionVisible to produce intermediate values between minLength and - // minOverscrollLength when the user is transitioning from regular - // scrolling to overscrolling, so we instead use the percentage of the - // content that is still in the viewport to determine the size of the - // thumb. iOS behavior appears to have the thumb reach its minimum size - // with ~20% of overscroll. We map the percentage of minLength from - // [0.8, 1.0] to [0.0, 1.0], so 0% to 20% of overscroll will produce - // values for the thumb that range between minLength and the smallest - // possible value, minOverscrollLength. - : safeMinLength * (1.0 - clampDouble(fractionOverscrolled, 0.0, 0.2) / 0.2); - - // The `thumbExtent` should be no greater than `trackSize`, otherwise - // the scrollbar may scroll towards the wrong direction. - final double extent = clampDouble(thumbExtent, newMinLength, _traversableTrackExtent); - return extent; - } - @override - void dispose() { - fadeoutOpacityAnimation.removeListener(notifyListeners); - super.dispose(); - } + void paint(Canvas canvas, Size size) { + if (_lastAxisDirection == null + || _lastMetrics == null + || _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) { + return; + } + // Skip painting if there's not enough space. + if (_traversableTrackExtent <= 0) { + return; + } + // Do not paint a scrollbar if the scroll view is infinitely long. + // TODO(Piinks): Special handling for infinite scroll views, + // https://github.com/flutter/flutter/issues/41434 + if (_lastMetrics!.maxScrollExtent.isInfinite) { + return; + } - bool get _isVertical => _lastAxisDirection == AxisDirection.down || _lastAxisDirection == AxisDirection.up; - bool get _isReversed => _lastAxisDirection == AxisDirection.up || _lastAxisDirection == AxisDirection.left; - // The amount of scroll distance before and after the current position. - double get _beforeExtent => _isReversed ? _lastMetrics!.extentAfter : _lastMetrics!.extentBefore; - double get _afterExtent => _isReversed ? _lastMetrics!.extentBefore : _lastMetrics!.extentAfter; - // Padding of the thumb track. - double get _mainAxisPadding => _isVertical ? padding.vertical : padding.horizontal; - // The length of the painted track. - double get _trackExtent => _lastMetrics!.viewportDimension - _mainAxisPadding; - // The length of the track that is traversable by the thumb. - double get _traversableTrackExtent => _trackExtent - (2 * mainAxisMargin); + _setThumbExtent(); + final double thumbPositionOffset = _getScrollToTrack(_lastMetrics!, _thumbExtent); + _thumbOffset = thumbPositionOffset + _leadingThumbMainAxisOffset; - // The total size of the scrollable content. - double get _totalContentExtent { - return _lastMetrics!.maxScrollExtent - - _lastMetrics!.minScrollExtent - + _lastMetrics!.viewportDimension; + return _paintScrollbar(canvas, size); } + // - Scroll Position Conversion + /// Convert between a thumb track position and the corresponding scroll /// position. /// @@ -628,7 +681,7 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { double getTrackToScroll(double thumbOffsetLocal) { assert(thumbOffsetLocal != null); final double scrollableExtent = _lastMetrics!.maxScrollExtent - _lastMetrics!.minScrollExtent; - final double thumbMovableExtent = _traversableTrackExtent - _thumbExtent(); + final double thumbMovableExtent = _traversableTrackExtent - _thumbExtent; return scrollableExtent * thumbOffsetLocal / thumbMovableExtent; } @@ -645,35 +698,27 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { return (_isReversed ? 1 - fractionPast : fractionPast) * (_traversableTrackExtent - thumbExtent); } - @override - void paint(Canvas canvas, Size size) { - if (_lastAxisDirection == null - || _lastMetrics == null - || _lastMetrics!.maxScrollExtent <= _lastMetrics!.minScrollExtent) { - return; - } + // - Hit Testing - // Skip painting if there's not enough space. - if (_lastMetrics!.viewportDimension <= _mainAxisPadding || _traversableTrackExtent <= 0) { - return; + @override + bool? hitTest(Offset? position) { + // There is nothing painted to hit. + if (_thumbRect == null) { + return null; } - final double beforePadding = _isVertical ? padding.top : padding.left; - final double thumbExtent = _thumbExtent(); - final double thumbOffsetLocal = _getScrollToTrack(_lastMetrics!, thumbExtent); - _thumbOffset = thumbOffsetLocal + mainAxisMargin + beforePadding; - - // Do not paint a scrollbar if the scroll view is infinitely long. - // TODO(Piinks): Special handling for infinite scroll views, https://github.com/flutter/flutter/issues/41434 - if (_lastMetrics!.maxScrollExtent.isInfinite) { - return; + // Interaction disabled. + if (ignorePointer + // The thumb is not able to be hit when transparent. + || fadeoutOpacityAnimation.value == 0.0 + // Not scrollable + || !_lastMetricsAreScrollable) { + return false; } - return _paintScrollbar(canvas, size, thumbExtent, _lastAxisDirection!); + return _trackRect!.contains(position!); } - bool get _lastMetricsAreScrollable => _lastMetrics!.minScrollExtent != _lastMetrics!.maxScrollExtent; - /// Same as hitTest, but includes some padding when the [PointerEvent] is /// caused by [PointerDeviceKind.touch] to make sure that the region /// isn't too small to be interacted with by the user. @@ -756,28 +801,6 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { } } - // Scrollbars are interactive. - @override - bool? hitTest(Offset? position) { - if (_thumbRect == null) { - return null; - } - if (ignorePointer) { - return false; - } - - // The thumb is not able to be hit when transparent. - if (fadeoutOpacityAnimation.value == 0.0) { - return false; - } - - if (!_lastMetricsAreScrollable) { - return false; - } - - return _trackRect!.contains(position!); - } - @override bool shouldRepaint(ScrollbarPainter oldDelegate) { // Should repaint if any properties changed. @@ -807,6 +830,12 @@ class ScrollbarPainter extends ChangeNotifier implements CustomPainter { @override String toString() => describeIdentity(this); + + @override + void dispose() { + fadeoutOpacityAnimation.removeListener(notifyListeners); + super.dispose(); + } } /// An extendable base class for building scrollbars that fade in and out. @@ -1669,7 +1698,7 @@ class RawScrollbarState extends State with TickerProv case TargetPlatform.iOS: case TargetPlatform.android: // We can only drag the scrollbar into overscroll on mobile - // platforms, and only if the physics allow it. + // platforms, and only then if the physics allow it. break; } position.jumpTo(newPosition); @@ -1908,7 +1937,7 @@ class RawScrollbarState extends State with TickerProv () => _ThumbPressGestureRecognizer( debugOwner: this, customPaintKey: _scrollbarPainterKey, - pressDuration: widget.pressDuration, + duration: widget.pressDuration, ), (_ThumbPressGestureRecognizer instance) { instance.onLongPress = handleThumbPress; @@ -2074,11 +2103,8 @@ class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer { _ThumbPressGestureRecognizer({ required Object super.debugOwner, required GlobalKey customPaintKey, - required Duration pressDuration, - }) : _customPaintKey = customPaintKey, - super( - duration: pressDuration, - ); + required super.duration, + }) : _customPaintKey = customPaintKey; final GlobalKey _customPaintKey; @@ -2105,10 +2131,9 @@ class _ThumbPressGestureRecognizer extends LongPressGestureRecognizer { // track and ignores everything else, including the thumb. class _TrackTapGestureRecognizer extends TapGestureRecognizer { _TrackTapGestureRecognizer({ - required Object debugOwner, + required super.debugOwner, required GlobalKey customPaintKey, - }) : _customPaintKey = customPaintKey, - super(debugOwner: debugOwner); + }) : _customPaintKey = customPaintKey; final GlobalKey _customPaintKey; diff --git a/packages/flutter/lib/src/widgets/unique_widget.dart b/packages/flutter/lib/src/widgets/unique_widget.dart index 30a5e6403d1ff..ee90cb50bf7a9 100644 --- a/packages/flutter/lib/src/widgets/unique_widget.dart +++ b/packages/flutter/lib/src/widgets/unique_widget.dart @@ -28,7 +28,7 @@ abstract class UniqueWidget> extends StatefulWid super(key: key); @override - T createState(); // ignore: no_logic_in_create_state, https://github.com/dart-lang/linter/issues/2345 + T createState(); /// The state for the unique inflated instance of this widget. /// diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index 6d2582309cdf2..55d3dbbc3033e 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -978,7 +978,7 @@ mixin WidgetInspectorService { bool enabled = false; assert(() { // TODO(kenz): add support for structured errors on the web. - enabled = const bool.fromEnvironment('flutter.inspector.structuredErrors', defaultValue: !kIsWeb); // ignore: avoid_redundant_argument_values + enabled = const bool.fromEnvironment('flutter.inspector.structuredErrors', defaultValue: !kIsWeb); return true; }()); return enabled; diff --git a/packages/flutter/test/foundation/_compute_caller_unsound_null_safety_error.dart b/packages/flutter/test/foundation/_compute_caller_unsound_null_safety_error.dart index 971670099b7f6..8ceaddbc9779f 100644 --- a/packages/flutter/test/foundation/_compute_caller_unsound_null_safety_error.dart +++ b/packages/flutter/test/foundation/_compute_caller_unsound_null_safety_error.dart @@ -16,7 +16,7 @@ void main() async { try { await compute(throwNull, null); } catch (e) { - if (e is! NullThrownError) { // ignore: deprecated_member_use + if (e is! NullThrownError) { throw Exception('compute returned bad result'); } } diff --git a/packages/flutter/test/foundation/assertions_test.dart b/packages/flutter/test/foundation/assertions_test.dart index 7428e38e494c1..88e1d1da468f0 100644 --- a/packages/flutter/test/foundation/assertions_test.dart +++ b/packages/flutter/test/foundation/assertions_test.dart @@ -60,7 +60,7 @@ void main() { ); expect( FlutterErrorDetails( - exception: NullThrownError(), // ignore: deprecated_member_use + exception: NullThrownError(), library: 'LIBRARY', context: ErrorDescription('CONTEXTING'), informationCollector: () sync* { @@ -113,7 +113,6 @@ void main() { '═════════════════════════════════════════════════════════════════\n', ); expect( - // ignore: deprecated_member_use FlutterErrorDetails(exception: NullThrownError()).toString(), '══╡ EXCEPTION CAUGHT BY FLUTTER FRAMEWORK ╞══════════════════════\n' 'The null value was thrown.\n' 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/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/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/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/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/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/material/scrollbar_test.dart b/packages/flutter/test/material/scrollbar_test.dart index 37a3f32401bda..f5cf45a456f95 100644 --- a/packages/flutter/test/material/scrollbar_test.dart +++ b/packages/flutter/test/material/scrollbar_test.dart @@ -148,11 +148,9 @@ void main() { viewportDimension: 100.0, axisDirection: AxisDirection.down, ); - // ignore: avoid_dynamic_calls scrollPainter!.update(metrics, AxisDirection.down); final TestCanvas canvas = TestCanvas(); - // ignore: avoid_dynamic_calls scrollPainter.paint(canvas, const Size(10.0, 100.0)); // Scrollbar is not supposed to draw anything if there isn't enough content. diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index 034888549315e..d7d16b0704b56 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -94,8 +94,9 @@ Widget overlayWithEntry(OverlayEntry entry) { ); } -Widget boilerplate({ required Widget child }) { +Widget boilerplate({ required Widget child, ThemeData? theme }) { return MaterialApp( + theme: theme, home: Localizations( locale: const Locale('en', 'US'), delegates: >[ @@ -4655,6 +4656,38 @@ void main() { expect(counterTextWidget.style!.color, isNot(equals(Colors.deepPurpleAccent))); }); + testWidgets('maxLength shows warning in Material 3', (WidgetTester tester) async { + final TextEditingController textController = TextEditingController(); + final ThemeData theme = ThemeData.from( + colorScheme: const ColorScheme.light().copyWith(error: Colors.deepPurpleAccent), + useMaterial3: true, + ); + await tester.pumpWidget(boilerplate( + theme: theme, + child: TextField( + controller: textController, + maxLength: 10, + maxLengthEnforcement: MaxLengthEnforcement.none, + ), + )); + + await tester.enterText(find.byType(TextField), '0123456789101112'); + await tester.pump(); + + expect(textController.text, '0123456789101112'); + expect(find.text('16/10'), findsOneWidget); + Text counterTextWidget = tester.widget(find.text('16/10')); + expect(counterTextWidget.style!.color, equals(Colors.deepPurpleAccent)); + + await tester.enterText(find.byType(TextField), '0123456789'); + await tester.pump(); + + expect(textController.text, '0123456789'); + expect(find.text('10/10'), findsOneWidget); + counterTextWidget = tester.widget(find.text('10/10')); + expect(counterTextWidget.style!.color, isNot(equals(Colors.deepPurpleAccent))); + }); + testWidgets('maxLength shows warning when maxLengthEnforcement.none with surrogate pairs.', (WidgetTester tester) async { final TextEditingController textController = TextEditingController(); const TextStyle testStyle = TextStyle(color: Colors.deepPurpleAccent); @@ -6086,6 +6119,60 @@ void main() { semantics.dispose(); }); + // Regressing test for https://github.com/flutter/flutter/issues/99763 + testWidgets('Update textField semantics when obscureText changes', (WidgetTester tester) async { + final SemanticsTester semantics = SemanticsTester(tester); + final TextEditingController controller = TextEditingController(); + await tester.pumpWidget(_ObscureTextTestWidget(controller: controller)); + + controller.text = 'Hello'; + await tester.pump(); + + expect( + semantics, + includesNodeWith( + actions: [SemanticsAction.tap], + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.isTextField, + ], + value: 'Hello', + ) + ); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + expect( + semantics, + includesNodeWith( + actions: [SemanticsAction.tap], + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.isTextField, + SemanticsFlag.isObscured, + ], + ) + ); + + await tester.tap(find.byType(ElevatedButton)); + await tester.pump(); + + expect( + semantics, + includesNodeWith( + actions: [SemanticsAction.tap], + textDirection: TextDirection.ltr, + flags: [ + SemanticsFlag.isTextField, + ], + value: 'Hello', + ) + ); + + semantics.dispose(); + }); + testWidgets('TextField semantics, enableInteractiveSelection = false', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); final TextEditingController controller = TextEditingController(); @@ -12171,3 +12258,40 @@ void main() { }); }); } + +/// A Simple widget for testing the obscure text. +class _ObscureTextTestWidget extends StatefulWidget { + const _ObscureTextTestWidget({ required this.controller }); + + final TextEditingController controller; + @override + _ObscureTextTestWidgetState createState() => _ObscureTextTestWidgetState(); +} + +class _ObscureTextTestWidgetState extends State<_ObscureTextTestWidget> { + + bool _obscureText = false; + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + body: Builder( + builder: (_) { + return Column( + children: [ + TextField( + obscureText: _obscureText, + controller: widget.controller, + ), + ElevatedButton( + onPressed: () => setState(() {_obscureText = !_obscureText;}), + child: const SizedBox.shrink(), + ), + ], + ); + }, + ), + ), + ); + } +} 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', diff --git a/packages/flutter/test/painting/painting_utils.dart b/packages/flutter/test/painting/painting_utils.dart index acc9bbd57ad88..b7edc0da1227a 100644 --- a/packages/flutter/test/painting/painting_utils.dart +++ b/packages/flutter/test/painting/painting_utils.dart @@ -20,7 +20,7 @@ class PaintingBindingSpy extends BindingBase with SchedulerBinding, ServicesBind } @override - // ignore: MUST_CALL_SUPER + // ignore: must_call_super void initLicenses() { // Do not include any licenses, because we're a test, and the LICENSE file // doesn't get generated for tests. diff --git a/packages/flutter/test/rendering/sliver_fixed_extent_layout_test.dart b/packages/flutter/test/rendering/sliver_fixed_extent_layout_test.dart index 196b9122757fe..3e7f9fa65ecce 100644 --- a/packages/flutter/test/rendering/sliver_fixed_extent_layout_test.dart +++ b/packages/flutter/test/rendering/sliver_fixed_extent_layout_test.dart @@ -222,7 +222,6 @@ class TestRenderSliverFixedExtentBoxAdaptor extends RenderSliverFixedExtentBoxAd :super(childManager: TestRenderSliverBoxChildManager(children: [])); @override - // ignore: unnecessary_overrides int getMaxChildIndexForScrollOffset(double scrollOffset, double itemExtent) { return super.getMaxChildIndexForScrollOffset(scrollOffset, itemExtent); } 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/hardware_keyboard_test.dart b/packages/flutter/test/services/hardware_keyboard_test.dart index b5ef83af97c11..529067c1070db 100644 --- a/packages/flutter/test/services/hardware_keyboard_test.dart +++ b/packages/flutter/test/services/hardware_keyboard_test.dart @@ -75,7 +75,6 @@ void main() { return false; }); // While ShiftLeft is held (the event of which was skipped), press keyA. - // ignore: prefer_const_declarations final Map rawMessage = kIsWeb ? ( KeyEventSimulator.getKeyData( LogicalKeyboardKey.keyA, 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/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/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/widgets/framework_test.dart b/packages/flutter/test/widgets/framework_test.dart index dad56050842ad..0787033d17ec2 100644 --- a/packages/flutter/test/widgets/framework_test.dart +++ b/packages/flutter/test/widgets/framework_test.dart @@ -1268,7 +1268,7 @@ void main() { }); testWidgets('scheduleBuild while debugBuildingDirtyElements is true', (WidgetTester tester) async { - /// ignore here is required for testing purpose because changing the flag properly is hard + // ignore here is required for testing purpose because changing the flag properly is hard // ignore: invalid_use_of_protected_member tester.binding.debugBuildingDirtyElements = true; late FlutterError error; diff --git a/packages/flutter/test/widgets/raw_keyboard_listener_test.dart b/packages/flutter/test/widgets/raw_keyboard_listener_test.dart index 3bbfcbc35fd96..64f927b5f95c9 100644 --- a/packages/flutter/test/widgets/raw_keyboard_listener_test.dart +++ b/packages/flutter/test/widgets/raw_keyboard_listener_test.dart @@ -62,7 +62,7 @@ void main() { focusNode.requestFocus(); await tester.idle(); - await tester.sendKeyEvent(LogicalKeyboardKey.metaLeft, platform: 'web'); // ignore: avoid_redundant_argument_values + await tester.sendKeyEvent(LogicalKeyboardKey.metaLeft, platform: 'web'); await tester.idle(); expect(events.length, 2); diff --git a/packages/flutter_goldens/test/flutter_goldens_test.dart b/packages/flutter_goldens/test/flutter_goldens_test.dart index 100c71b3d86bc..e64198f0bc486 100644 --- a/packages/flutter_goldens/test/flutter_goldens_test.dart +++ b/packages/flutter_goldens/test/flutter_goldens_test.dart @@ -489,6 +489,94 @@ void main() { ); }); + test('throws for error state from imgtestAdd', () { + final File goldenFile = fs.file('/workDirectory/temp/golden_file_test.png') + ..createSync(recursive: true); + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'GOLDCTL' : 'goldctl', + }, + operatingSystem: 'macos' + ); + + skiaClient = SkiaGoldClient( + workDirectory, + fs: fs, + process: process, + platform: platform, + httpClient: fakeHttpClient, + ); + + const RunInvocation goldctlInvocation = RunInvocation( + [ + 'goldctl', + 'imgtest', 'add', + '--work-dir', '/workDirectory/temp', + '--test-name', 'golden_file_test', + '--png-file', '/workDirectory/temp/golden_file_test.png', + '--passfail', + ], + null, + ); + process.processResults[goldctlInvocation] = ProcessResult(123, 1, 'Expected failure', 'Expected failure'); + process.fallbackProcessResult = ProcessResult(123, 1, 'Fallback failure', 'Fallback failure'); + + expect( + skiaClient.imgtestAdd('golden_file_test', goldenFile), + throwsA( + isA().having((SkiaException error) => error.message, + 'message', + contains('result-state.json'), + ), + ), + ); + }); + + test('throws for error state from tryjobAdd', () { + final File goldenFile = fs.file('/workDirectory/temp/golden_file_test.png') + ..createSync(recursive: true); + platform = FakePlatform( + environment: { + 'FLUTTER_ROOT': _kFlutterRoot, + 'GOLDCTL' : 'goldctl', + }, + operatingSystem: 'macos' + ); + + skiaClient = SkiaGoldClient( + workDirectory, + fs: fs, + process: process, + platform: platform, + httpClient: fakeHttpClient, + ); + + const RunInvocation goldctlInvocation = RunInvocation( + [ + 'goldctl', + 'imgtest', 'add', + '--work-dir', '/workDirectory/temp', + '--test-name', 'golden_file_test', + '--png-file', '/workDirectory/temp/golden_file_test.png', + '--passfail', + ], + null, + ); + process.processResults[goldctlInvocation] = ProcessResult(123, 1, 'Expected failure', 'Expected failure'); + process.fallbackProcessResult = ProcessResult(123, 1, 'Fallback failure', 'Fallback failure'); + + expect( + skiaClient.tryjobAdd('golden_file_test', goldenFile), + throwsA( + isA().having((SkiaException error) => error.message, + 'message', + contains('result-state.json'), + ), + ), + ); + }); + group('Request Handling', () { const String expectation = '55109a4bed52acc780530f7a9aeff6c0'; diff --git a/packages/flutter_goldens_client/lib/skia_client.dart b/packages/flutter_goldens_client/lib/skia_client.dart index 351f1dcb7bf38..50322ef27ea2d 100644 --- a/packages/flutter_goldens_client/lib/skia_client.dart +++ b/packages/flutter_goldens_client/lib/skia_client.dart @@ -21,6 +21,21 @@ const String _kGoldctlKey = 'GOLDCTL'; const String _kTestBrowserKey = 'FLUTTER_TEST_BROWSER'; const String _kWebRendererKey = 'FLUTTER_WEB_RENDERER'; +/// Exception thrown when an error is returned from the [SkiaClient]. +class SkiaException implements Exception { + /// Creates a new `SkiaException` with a required error [message]. + const SkiaException(this.message); + + /// A message describing the error. + final String message; + + /// Returns a description of the Skia exception. + /// + /// The description always contains the [message]. + @override + String toString() => 'SkiaException: $message'; +} + /// A client for uploading image tests and making baseline requests to the /// Flutter Gold Dashboard. class SkiaGoldClient { @@ -103,10 +118,10 @@ class SkiaGoldClient { ..writeln('Luci environments authenticate using the file provided ' 'by LUCI_CONTEXT. There may be an error with this file or Gold ' 'authentication.') - ..writeln('Debug information for Gold:') + ..writeln('Debug information for Gold --------------------------------') ..writeln('stdout: ${result.stdout}') ..writeln('stderr: ${result.stderr}'); - throw Exception(buf.toString()); + throw SkiaException(buf.toString()); } } @@ -155,7 +170,7 @@ class SkiaGoldClient { ..writeln('Please confirm the settings of your golden file test.') ..writeln('Arguments provided:'); imgtestInitCommand.forEach(buf.writeln); - throw Exception(buf.toString()); + throw SkiaException(buf.toString()); } final io.ProcessResult result = await process.run(imgtestInitCommand); @@ -167,10 +182,10 @@ class SkiaGoldClient { ..writeln('An error occurred when initializing golden file test with ') ..writeln('goldctl.') ..writeln() - ..writeln('Debug information for Gold:') + ..writeln('Debug information for Gold --------------------------------') ..writeln('stdout: ${result.stdout}') ..writeln('stderr: ${result.stderr}'); - throw Exception(buf.toString()); + throw SkiaException(buf.toString()); } _initialized = true; } @@ -202,6 +217,14 @@ class SkiaGoldClient { if (result.exitCode != 0) { // If an unapproved image has made it to post-submit, throw to close the // tree. + String? resultContents; + final File resultFile = workDirectory.childFile(fs.path.join( + 'result-state.json', + )); + if(await resultFile.exists()) { + resultContents = await resultFile.readAsString(); + } + final StringBuffer buf = StringBuffer() ..writeln('Skia Gold received an unapproved image in post-submit ') ..writeln('testing. Golden file images in flutter/flutter are triaged ') @@ -212,10 +235,12 @@ class SkiaGoldClient { ..writeln('information, visit the wiki: ') ..writeln('https://github.com/flutter/flutter/wiki/Writing-a-golden-file-test-for-package:flutter') ..writeln() - ..writeln('Debug information for Gold:') + ..writeln('Debug information for Gold --------------------------------') ..writeln('stdout: ${result.stdout}') - ..writeln('stderr: ${result.stderr}'); - throw Exception(buf.toString()); + ..writeln('stderr: ${result.stderr}') + ..writeln() + ..writeln('result-state.json: ${resultContents ?? 'No result file found.'}'); + throw SkiaException(buf.toString()); } return true; @@ -269,7 +294,7 @@ class SkiaGoldClient { ..writeln('Please confirm the settings of your golden file test.') ..writeln('Arguments provided:'); imgtestInitCommand.forEach(buf.writeln); - throw Exception(buf.toString()); + throw SkiaException(buf.toString()); } final io.ProcessResult result = await process.run(imgtestInitCommand); @@ -281,10 +306,10 @@ class SkiaGoldClient { ..writeln('An error occurred when initializing golden file tryjob with ') ..writeln('goldctl.') ..writeln() - ..writeln('Debug information for Gold:') + ..writeln('Debug information for Gold --------------------------------') ..writeln('stdout: ${result.stdout}') ..writeln('stderr: ${result.stderr}'); - throw Exception(buf.toString()); + throw SkiaException(buf.toString()); } _tryjobInitialized = true; } @@ -315,16 +340,25 @@ class SkiaGoldClient { final String/*!*/ resultStdout = result.stdout.toString(); if (result.exitCode != 0 && !(resultStdout.contains('Untriaged') || resultStdout.contains('negative image'))) { + String? resultContents; + final File resultFile = workDirectory.childFile(fs.path.join( + 'result-state.json', + )); + if(await resultFile.exists()) { + resultContents = await resultFile.readAsString(); + } final StringBuffer buf = StringBuffer() ..writeln('Unexpected Gold tryjobAdd failure.') ..writeln('Tryjob execution for golden file test $testName failed for') ..writeln('a reason unrelated to pixel comparison.') ..writeln() - ..writeln('Debug information for Gold:') + ..writeln('Debug information for Gold --------------------------------') ..writeln('stdout: ${result.stdout}') ..writeln('stderr: ${result.stderr}') - ..writeln(); - throw Exception(buf.toString()); + ..writeln() + ..writeln() + ..writeln('result-state.json: ${resultContents ?? 'No result file found.'}'); + throw SkiaException(buf.toString()); } } @@ -432,14 +466,14 @@ class SkiaGoldClient { /// Returns the current commit hash of the Flutter repository. Future _getCurrentCommit() async { if (!_flutterRoot.existsSync()) { - throw Exception('Flutter root could not be found: $_flutterRoot\n'); + throw SkiaException('Flutter root could not be found: $_flutterRoot\n'); } else { final io.ProcessResult revParse = await process.run( ['git', 'rev-parse', 'HEAD'], workingDirectory: _flutterRoot.path, ); if (revParse.exitCode != 0) { - throw Exception('Current commit of Flutter can not be found.'); + throw const SkiaException('Current commit of Flutter can not be found.'); } return (revParse.stdout as String/*!*/).trim(); } diff --git a/packages/flutter_test/lib/src/binding.dart b/packages/flutter_test/lib/src/binding.dart index 3c74950cfa95a..3245f9acfff31 100644 --- a/packages/flutter_test/lib/src/binding.dart +++ b/packages/flutter_test/lib/src/binding.dart @@ -325,7 +325,7 @@ abstract class TestWidgetsFlutterBinding extends BindingBase } @override - // ignore: MUST_CALL_SUPER + // ignore: must_call_super void initLicenses() { // Do not include any licenses, because we're a test, and the LICENSE file // doesn't get generated for tests. @@ -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/event_simulation.dart b/packages/flutter_test/lib/src/event_simulation.dart index 147b274056a14..89904207c5a23 100644 --- a/packages/flutter_test/lib/src/event_simulation.dart +++ b/packages/flutter_test/lib/src/event_simulation.dart @@ -36,7 +36,6 @@ String? _keyLabel(LogicalKeyboardKey key) { return null; } -// ignore: avoid_classes_with_only_static_members /// A class that serves as a namespace for a bunch of keyboard-key generation /// utilities. class KeyEventSimulator { @@ -902,8 +901,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 +933,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/finders.dart b/packages/flutter_test/lib/src/finders.dart index f5262d614aa9c..51b3160dda80e 100644 --- a/packages/flutter_test/lib/src/finders.dart +++ b/packages/flutter_test/lib/src/finders.dart @@ -923,7 +923,8 @@ class _DescendantFinder extends Finder { @override Iterable apply(Iterable candidates) { - return candidates.where((Element element) => descendant.evaluate().contains(element)); + final Iterable descendants = descendant.evaluate(); + return candidates.where((Element element) => descendants.contains(element)); } @override @@ -956,7 +957,8 @@ class _AncestorFinder extends Finder { @override Iterable apply(Iterable candidates) { - return candidates.where((Element element) => ancestor.evaluate().contains(element)); + final Iterable ancestors = ancestor.evaluate(); + return candidates.where((Element element) => ancestors.contains(element)); } @override 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/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/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()), + ), + ); + } } 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', () { 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()); } diff --git a/packages/flutter_test/test/widget_tester_test.dart b/packages/flutter_test/test/widget_tester_test.dart index 9d35cb61e681c..3eddb29e72103 100644 --- a/packages/flutter_test/test/widget_tester_test.dart +++ b/packages/flutter_test/test/widget_tester_test.dart @@ -361,6 +361,30 @@ void main() { matchRoot: true, ), findsOneWidget); }); + + testWidgets('is fast in deep tree', (WidgetTester tester) async { + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: _deepWidgetTree( + depth: 1000, + child: Row( + children: [ + _deepWidgetTree( + depth: 1000, + child: Column(children: fooBarTexts), + ), + ], + ), + ), + ), + ); + + expect(find.ancestor( + of: find.text('bar'), + matching: find.byType(Row), + ), findsOneWidget); + }); }); group('pageBack', () { @@ -854,3 +878,12 @@ class _AlwaysRepaint extends CustomPainter { onPaint(); } } + +/// Wraps [child] in [depth] layers of [SizedBox] +Widget _deepWidgetTree({required int depth, required Widget child}) { + Widget tree = child; + for (int i = 0; i < depth; i += 1) { + tree = SizedBox(child: tree); + } + return tree; +} 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/lib/src/version.dart b/packages/flutter_tools/lib/src/version.dart index dc47f17057179..6c6c7e4f2127b 100644 --- a/packages/flutter_tools/lib/src/version.dart +++ b/packages/flutter_tools/lib/src/version.dart @@ -100,7 +100,7 @@ class FlutterVersion { String? _repositoryUrl; String? get repositoryUrl { - final String _ = channel; // ignore: no_leading_underscores_for_local_identifiers + final String _ = channel; return _repositoryUrl; } 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