From 4d5a1d91e136c8ab05c567aa581bea1eacf364db Mon Sep 17 00:00:00 2001 From: Zachary Anderson Date: Wed, 13 Sep 2023 10:36:24 -0700 Subject: [PATCH 01/16] Bump gradle heap size limit in *everywhere* (#134665) I'm seeing these in the bot reports every week. Hopefully this is all of them, and hopefully 4GB is enough. --- dev/a11y_assessments/android/gradle.properties | 2 +- dev/benchmarks/complex_layout/android/gradle.properties | 2 +- dev/benchmarks/macrobenchmarks/android/gradle.properties | 2 +- dev/benchmarks/microbenchmarks/android/gradle.properties | 2 +- dev/benchmarks/multiple_flutters/android/gradle.properties | 4 ++-- .../platform_views_layout/android/gradle.properties | 2 +- .../android/gradle.properties | 2 +- dev/benchmarks/test_apps/stocks/android/gradle.properties | 2 +- .../abstract_method_smoke_test/android/gradle.properties | 2 +- .../android_custom_host_app/gradle.properties | 2 +- .../android_embedding_v2_smoke_test/android/gradle.properties | 2 +- .../android_host_app_v2_embedding/gradle.properties | 2 +- .../android_semantics_testing/android/gradle.properties | 2 +- dev/integration_tests/android_views/android/gradle.properties | 2 +- dev/integration_tests/channels/android/gradle.properties | 2 +- .../deferred_components_test/android/gradle.properties | 2 +- dev/integration_tests/external_ui/android/gradle.properties | 2 +- dev/integration_tests/flavors/android/gradle.properties | 2 +- .../gradle_deprecated_settings/android/gradle.properties | 4 ++-- .../hybrid_android_views/android/gradle.properties | 2 +- .../gradle.properties | 2 +- dev/integration_tests/non_nullable/android/gradle.properties | 2 +- .../platform_interaction/android/gradle.properties | 2 +- .../release_smoke_test/android/gradle.properties | 2 +- dev/integration_tests/spell_check/android/gradle.properties | 2 +- dev/integration_tests/ui/android/gradle.properties | 2 +- dev/manual_tests/android/gradle.properties | 2 +- dev/tracing_tests/android/gradle.properties | 2 +- examples/api/android/gradle.properties | 2 +- examples/hello_world/android/gradle.properties | 2 +- examples/image_list/android/gradle.properties | 2 +- examples/layers/android/gradle.properties | 2 +- examples/platform_channel/android/gradle.properties | 2 +- .../android_plugin_example_app_build_test.dart | 2 +- .../test_data/deferred_components_project.dart | 2 +- .../test/integration.shard/test_data/multidex_project.dart | 2 +- packages/integration_test/android/gradle.properties | 2 +- packages/integration_test/example/android/gradle.properties | 2 +- 38 files changed, 40 insertions(+), 40 deletions(-) diff --git a/dev/a11y_assessments/android/gradle.properties b/dev/a11y_assessments/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/a11y_assessments/android/gradle.properties +++ b/dev/a11y_assessments/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/benchmarks/complex_layout/android/gradle.properties b/dev/benchmarks/complex_layout/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/benchmarks/complex_layout/android/gradle.properties +++ b/dev/benchmarks/complex_layout/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/benchmarks/macrobenchmarks/android/gradle.properties b/dev/benchmarks/macrobenchmarks/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/benchmarks/macrobenchmarks/android/gradle.properties +++ b/dev/benchmarks/macrobenchmarks/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/benchmarks/microbenchmarks/android/gradle.properties b/dev/benchmarks/microbenchmarks/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/benchmarks/microbenchmarks/android/gradle.properties +++ b/dev/benchmarks/microbenchmarks/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/benchmarks/multiple_flutters/android/gradle.properties b/dev/benchmarks/multiple_flutters/android/gradle.properties index 98bed167dc90f..9930279818e98 100644 --- a/dev/benchmarks/multiple_flutters/android/gradle.properties +++ b/dev/benchmarks/multiple_flutters/android/gradle.properties @@ -6,7 +6,7 @@ # http://www.gradle.org/docs/current/userguide/build_environment.html # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +org.gradle.jvmargs=-Xmx4G -Dfile.encoding=UTF-8 # When configured, Gradle will run in incubating parallel mode. # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects @@ -18,4 +18,4 @@ android.useAndroidX=true # Automatically convert third-party libraries to use AndroidX android.enableJetifier=true # Kotlin code style for this project: "official" or "obsolete": -kotlin.code.style=official \ No newline at end of file +kotlin.code.style=official diff --git a/dev/benchmarks/platform_views_layout/android/gradle.properties b/dev/benchmarks/platform_views_layout/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/benchmarks/platform_views_layout/android/gradle.properties +++ b/dev/benchmarks/platform_views_layout/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/benchmarks/platform_views_layout_hybrid_composition/android/gradle.properties b/dev/benchmarks/platform_views_layout_hybrid_composition/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/benchmarks/platform_views_layout_hybrid_composition/android/gradle.properties +++ b/dev/benchmarks/platform_views_layout_hybrid_composition/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/benchmarks/test_apps/stocks/android/gradle.properties b/dev/benchmarks/test_apps/stocks/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/benchmarks/test_apps/stocks/android/gradle.properties +++ b/dev/benchmarks/test_apps/stocks/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/abstract_method_smoke_test/android/gradle.properties b/dev/integration_tests/abstract_method_smoke_test/android/gradle.properties index 08f2b5f91bff6..f17eebabc3990 100644 --- a/dev/integration_tests/abstract_method_smoke_test/android/gradle.properties +++ b/dev/integration_tests/abstract_method_smoke_test/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.enableJetifier=true android.useAndroidX=true diff --git a/dev/integration_tests/android_custom_host_app/gradle.properties b/dev/integration_tests/android_custom_host_app/gradle.properties index 759a1767410a2..7413f6ce06495 100644 --- a/dev/integration_tests/android_custom_host_app/gradle.properties +++ b/dev/integration_tests/android_custom_host_app/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true flutter.hostAppProjectName=SampleApp diff --git a/dev/integration_tests/android_embedding_v2_smoke_test/android/gradle.properties b/dev/integration_tests/android_embedding_v2_smoke_test/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/android_embedding_v2_smoke_test/android/gradle.properties +++ b/dev/integration_tests/android_embedding_v2_smoke_test/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/android_host_app_v2_embedding/gradle.properties b/dev/integration_tests/android_host_app_v2_embedding/gradle.properties index 47a56de84bd00..598d13fee4463 100644 --- a/dev/integration_tests/android_host_app_v2_embedding/gradle.properties +++ b/dev/integration_tests/android_host_app_v2_embedding/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/android_semantics_testing/android/gradle.properties b/dev/integration_tests/android_semantics_testing/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/android_semantics_testing/android/gradle.properties +++ b/dev/integration_tests/android_semantics_testing/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/android_views/android/gradle.properties b/dev/integration_tests/android_views/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/android_views/android/gradle.properties +++ b/dev/integration_tests/android_views/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/channels/android/gradle.properties b/dev/integration_tests/channels/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/channels/android/gradle.properties +++ b/dev/integration_tests/channels/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/deferred_components_test/android/gradle.properties b/dev/integration_tests/deferred_components_test/android/gradle.properties index 507f4a3fcd52a..e5a6f71ad43f8 100644 --- a/dev/integration_tests/deferred_components_test/android/gradle.properties +++ b/dev/integration_tests/deferred_components_test/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true android.enableR8=true diff --git a/dev/integration_tests/external_ui/android/gradle.properties b/dev/integration_tests/external_ui/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/external_ui/android/gradle.properties +++ b/dev/integration_tests/external_ui/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/flavors/android/gradle.properties b/dev/integration_tests/flavors/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/flavors/android/gradle.properties +++ b/dev/integration_tests/flavors/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/gradle_deprecated_settings/android/gradle.properties b/dev/integration_tests/gradle_deprecated_settings/android/gradle.properties index 4d3226abc21bb..598d13fee4463 100644 --- a/dev/integration_tests/gradle_deprecated_settings/android/gradle.properties +++ b/dev/integration_tests/gradle_deprecated_settings/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true -android.enableJetifier=true \ No newline at end of file +android.enableJetifier=true diff --git a/dev/integration_tests/hybrid_android_views/android/gradle.properties b/dev/integration_tests/hybrid_android_views/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/hybrid_android_views/android/gradle.properties +++ b/dev/integration_tests/hybrid_android_views/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/module_host_with_custom_build_v2_embedding/gradle.properties b/dev/integration_tests/module_host_with_custom_build_v2_embedding/gradle.properties index 47a56de84bd00..598d13fee4463 100644 --- a/dev/integration_tests/module_host_with_custom_build_v2_embedding/gradle.properties +++ b/dev/integration_tests/module_host_with_custom_build_v2_embedding/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536m +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/non_nullable/android/gradle.properties b/dev/integration_tests/non_nullable/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/non_nullable/android/gradle.properties +++ b/dev/integration_tests/non_nullable/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/platform_interaction/android/gradle.properties b/dev/integration_tests/platform_interaction/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/platform_interaction/android/gradle.properties +++ b/dev/integration_tests/platform_interaction/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/release_smoke_test/android/gradle.properties b/dev/integration_tests/release_smoke_test/android/gradle.properties index d1ab454e543a7..4512f01215d27 100644 --- a/dev/integration_tests/release_smoke_test/android/gradle.properties +++ b/dev/integration_tests/release_smoke_test/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true android.useAndroidX=true diff --git a/dev/integration_tests/spell_check/android/gradle.properties b/dev/integration_tests/spell_check/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/spell_check/android/gradle.properties +++ b/dev/integration_tests/spell_check/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/integration_tests/ui/android/gradle.properties b/dev/integration_tests/ui/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/integration_tests/ui/android/gradle.properties +++ b/dev/integration_tests/ui/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/manual_tests/android/gradle.properties b/dev/manual_tests/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/manual_tests/android/gradle.properties +++ b/dev/manual_tests/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/dev/tracing_tests/android/gradle.properties b/dev/tracing_tests/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/dev/tracing_tests/android/gradle.properties +++ b/dev/tracing_tests/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/examples/api/android/gradle.properties b/examples/api/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/examples/api/android/gradle.properties +++ b/examples/api/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/examples/hello_world/android/gradle.properties b/examples/hello_world/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/examples/hello_world/android/gradle.properties +++ b/examples/hello_world/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/examples/image_list/android/gradle.properties b/examples/image_list/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/examples/image_list/android/gradle.properties +++ b/examples/image_list/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/examples/layers/android/gradle.properties b/examples/layers/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/examples/layers/android/gradle.properties +++ b/examples/layers/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/examples/platform_channel/android/gradle.properties b/examples/platform_channel/android/gradle.properties index 94adc3a3f97aa..598d13fee4463 100644 --- a/examples/platform_channel/android/gradle.properties +++ b/examples/platform_channel/android/gradle.properties @@ -1,3 +1,3 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true diff --git a/packages/flutter_tools/test/integration.shard/android_plugin_example_app_build_test.dart b/packages/flutter_tools/test/integration.shard/android_plugin_example_app_build_test.dart index 9c91e16d2767c..3a4d27477f6b3 100644 --- a/packages/flutter_tools/test/integration.shard/android_plugin_example_app_build_test.dart +++ b/packages/flutter_tools/test/integration.shard/android_plugin_example_app_build_test.dart @@ -112,7 +112,7 @@ void main() { expect(gradleProperties, exists); gradleProperties.writeAsStringSync(''' -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true android.enableR8=true'''); diff --git a/packages/flutter_tools/test/integration.shard/test_data/deferred_components_project.dart b/packages/flutter_tools/test/integration.shard/test_data/deferred_components_project.dart index 927cd4e457df4..4a0ec4be1cd30 100644 --- a/packages/flutter_tools/test/integration.shard/test_data/deferred_components_project.dart +++ b/packages/flutter_tools/test/integration.shard/test_data/deferred_components_project.dart @@ -232,7 +232,7 @@ class BasicDeferredComponentsConfig extends DeferredComponentsConfig { @override String get androidGradleProperties => ''' - org.gradle.jvmargs=-Xmx1536M + org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true android.enableR8=true diff --git a/packages/flutter_tools/test/integration.shard/test_data/multidex_project.dart b/packages/flutter_tools/test/integration.shard/test_data/multidex_project.dart index f5e245b426f0b..80ffcec941603 100644 --- a/packages/flutter_tools/test/integration.shard/test_data/multidex_project.dart +++ b/packages/flutter_tools/test/integration.shard/test_data/multidex_project.dart @@ -194,7 +194,7 @@ class MultidexProject extends Project { '''; String get androidGradleProperties => ''' - org.gradle.jvmargs=-Xmx1536M + org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true android.enableR8=true diff --git a/packages/integration_test/android/gradle.properties b/packages/integration_test/android/gradle.properties index 8bd86f6805108..95b4763a84734 100644 --- a/packages/integration_test/android/gradle.properties +++ b/packages/integration_test/android/gradle.properties @@ -1 +1 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G diff --git a/packages/integration_test/example/android/gradle.properties b/packages/integration_test/example/android/gradle.properties index 53a43b8d74fff..d513e21975fc1 100644 --- a/packages/integration_test/example/android/gradle.properties +++ b/packages/integration_test/example/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx4G android.useAndroidX=true android.enableJetifier=true From af5ac930d8e6fa3011b279343a01700c63c7daaa Mon Sep 17 00:00:00 2001 From: Victoria Ashworth <15619084+vashworth@users.noreply.github.com> Date: Wed, 13 Sep 2023 13:08:35 -0500 Subject: [PATCH 02/16] Set the CONFIGURATION_BUILD_DIR in generated xcconfig when debugging core device (#134493) Xcode uses the CONFIGURATION_BUILD_DIR build setting to determine the location of the bundle to build and install. When launching an app via Xcode with the Xcode debug workflow (for iOS 17 physical devices), temporarily set the CONFIGURATION_BUILD_DIR to the location of the bundle so Xcode can find it. Also, added a Xcode Debug version of the `microbenchmarks_ios` integration test since it uses `flutter run --profile` without using `--use-application-binary`. Fixes https://github.com/flutter/flutter/issues/134186. --- .ci.yaml | 11 +++ TESTOWNERS | 1 + .../microbenchmarks_ios_xcode_debug.dart | 21 +++++ dev/devicelab/lib/microbenchmarks.dart | 6 ++ dev/devicelab/lib/tasks/microbenchmarks.dart | 7 +- .../flutter_tools/lib/src/ios/devices.dart | 30 ++++++- packages/flutter_tools/lib/src/ios/mac.dart | 2 - .../lib/src/ios/xcode_build_settings.dart | 13 +-- .../ios_device_start_nonprebuilt_test.dart | 82 +++++++++++++++++++ .../general.shard/ios/xcodeproj_test.dart | 41 ++-------- 10 files changed, 170 insertions(+), 44 deletions(-) create mode 100644 dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart diff --git a/.ci.yaml b/.ci.yaml index 57c5c163479d0..e01d5b5ccd341 100644 --- a/.ci.yaml +++ b/.ci.yaml @@ -4069,6 +4069,17 @@ targets: ["devicelab", "ios", "mac"] task_name: microbenchmarks_ios + # TODO(vashworth): Remove after Xcode 15 and iOS 17 are in CI (https://github.com/flutter/flutter/issues/132128) + - name: Mac_ios microbenchmarks_ios_xcode_debug + recipe: devicelab/devicelab_drone + presubmit: false + timeout: 60 + properties: + tags: > + ["devicelab", "ios", "mac"] + task_name: microbenchmarks_ios_xcode_debug + bringup: true + - name: Mac_ios native_assets_ios_simulator recipe: devicelab/devicelab_drone presubmit: false diff --git a/TESTOWNERS b/TESTOWNERS index 635215f523090..bdb7499e3cee8 100644 --- a/TESTOWNERS +++ b/TESTOWNERS @@ -199,6 +199,7 @@ /dev/devicelab/bin/tasks/large_image_changer_perf_ios.dart @zanderso @flutter/engine /dev/devicelab/bin/tasks/macos_chrome_dev_mode.dart @zanderso @flutter/tool /dev/devicelab/bin/tasks/microbenchmarks_ios.dart @cyanglaz @flutter/engine +/dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart @vashworth @flutter/engine /dev/devicelab/bin/tasks/native_assets_ios_simulator.dart @dacoharkes @flutter/ios /dev/devicelab/bin/tasks/native_assets_ios.dart @dacoharkes @flutter/ios /dev/devicelab/bin/tasks/native_platform_view_ui_tests_ios.dart @hellohuanlin @flutter/ios diff --git a/dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart b/dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart new file mode 100644 index 0000000000000..3373a683672e8 --- /dev/null +++ b/dev/devicelab/bin/tasks/microbenchmarks_ios_xcode_debug.dart @@ -0,0 +1,21 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_devicelab/framework/devices.dart'; +import 'package:flutter_devicelab/framework/framework.dart'; +import 'package:flutter_devicelab/tasks/microbenchmarks.dart'; + +/// Runs microbenchmarks on iOS. +Future main() async { + // XcodeDebug workflow is used for CoreDevices (iOS 17+ and Xcode 15+). Use + // FORCE_XCODE_DEBUG environment variable to force the use of XcodeDebug + // workflow in CI to test from older versions since devicelab has not yet been + // updated to iOS 17 and Xcode 15. + deviceOperatingSystem = DeviceOperatingSystem.ios; + await task(createMicrobenchmarkTask( + environment: { + 'FORCE_XCODE_DEBUG': 'true', + }, + )); +} diff --git a/dev/devicelab/lib/microbenchmarks.dart b/dev/devicelab/lib/microbenchmarks.dart index 0cf3d8192466e..451be27b1a550 100644 --- a/dev/devicelab/lib/microbenchmarks.dart +++ b/dev/devicelab/lib/microbenchmarks.dart @@ -64,6 +64,12 @@ Future> readJsonResults(Process process) { // See https://github.com/flutter/flutter/issues/19208 process.stdin.write('q'); await process.stdin.flush(); + + // Give the process a couple of seconds to exit and run shutdown hooks + // before sending kill signal. + // TODO(fujino): https://github.com/flutter/flutter/issues/134566 + await Future.delayed(const Duration(seconds: 2)); + // Also send a kill signal in case the `q` above didn't work. process.kill(ProcessSignal.sigint); try { diff --git a/dev/devicelab/lib/tasks/microbenchmarks.dart b/dev/devicelab/lib/tasks/microbenchmarks.dart index 967bb58dfe607..6bd01aac1d2e8 100644 --- a/dev/devicelab/lib/tasks/microbenchmarks.dart +++ b/dev/devicelab/lib/tasks/microbenchmarks.dart @@ -15,7 +15,10 @@ import '../microbenchmarks.dart'; /// Creates a device lab task that runs benchmarks in /// `dev/benchmarks/microbenchmarks` reports results to the dashboard. -TaskFunction createMicrobenchmarkTask({bool? enableImpeller}) { +TaskFunction createMicrobenchmarkTask({ + bool? enableImpeller, + Map environment = const {}, +}) { return () async { final Device device = await devices.workingDevice; await device.unlock(); @@ -41,9 +44,9 @@ TaskFunction createMicrobenchmarkTask({bool? enableImpeller}) { return startFlutter( 'run', options: options, + environment: environment, ); }); - return readJsonResults(flutterProcess); } diff --git a/packages/flutter_tools/lib/src/ios/devices.dart b/packages/flutter_tools/lib/src/ios/devices.dart index fa6961e5e4611..e7133b0ad3228 100644 --- a/packages/flutter_tools/lib/src/ios/devices.dart +++ b/packages/flutter_tools/lib/src/ios/devices.dart @@ -34,6 +34,7 @@ import 'ios_deploy.dart'; import 'ios_workflow.dart'; import 'iproxy.dart'; import 'mac.dart'; +import 'xcode_build_settings.dart'; import 'xcode_debug.dart'; import 'xcodeproj.dart'; @@ -500,7 +501,6 @@ class IOSDevice extends Device { targetOverride: mainPath, activeArch: cpuArchitecture, deviceID: id, - isCoreDevice: isCoreDevice || forceXcodeDebugWorkflow, ); if (!buildResult.success) { _logger.printError('Could not build the precompiled application for the device.'); @@ -551,6 +551,7 @@ class IOSDevice extends Device { debuggingOptions: debuggingOptions, package: package, launchArguments: launchArguments, + mainPath: mainPath, discoveryTimeout: discoveryTimeout, shutdownHooks: shutdownHooks ?? globals.shutdownHooks, ) ? 0 : 1; @@ -784,6 +785,7 @@ class IOSDevice extends Device { required DebuggingOptions debuggingOptions, required IOSApp package, required List launchArguments, + required String? mainPath, required ShutdownHooks shutdownHooks, @visibleForTesting Duration? discoveryTimeout, }) async { @@ -822,6 +824,7 @@ class IOSDevice extends Device { }); XcodeDebugProject debugProject; + final FlutterProject flutterProject = FlutterProject.current(); if (package is PrebuiltIOSApp) { debugProject = await _xcodeDebug.createXcodeProjectWithCustomBundle( @@ -830,6 +833,19 @@ class IOSDevice extends Device { verboseLogging: _logger.isVerbose, ); } else if (package is BuildableIOSApp) { + // Before installing/launching/debugging with Xcode, update the build + // settings to use a custom configuration build directory so Xcode + // knows where to find the app bundle to launch. + final Directory bundle = _fileSystem.directory( + package.deviceBundlePath, + ); + await updateGeneratedXcodeProperties( + project: flutterProject, + buildInfo: debuggingOptions.buildInfo, + targetOverride: mainPath, + configurationBuildDir: bundle.parent.absolute.path, + ); + final IosProject project = package.project; final XcodeProjectInfo? projectInfo = await project.projectInfo(); if (projectInfo == null) { @@ -870,6 +886,18 @@ class IOSDevice extends Device { shutdownHooks.addShutdownHook(() => _xcodeDebug.exit(force: true)); } + if (package is BuildableIOSApp) { + // After automating Xcode, reset the Generated settings to not include + // the custom configuration build directory. This is to prevent + // confusion if the project is later ran via Xcode rather than the + // Flutter CLI. + await updateGeneratedXcodeProperties( + project: flutterProject, + buildInfo: debuggingOptions.buildInfo, + targetOverride: mainPath, + ); + } + return debugSuccess; } } diff --git a/packages/flutter_tools/lib/src/ios/mac.dart b/packages/flutter_tools/lib/src/ios/mac.dart index f51d436b971e2..8819b5cfe1dea 100644 --- a/packages/flutter_tools/lib/src/ios/mac.dart +++ b/packages/flutter_tools/lib/src/ios/mac.dart @@ -133,7 +133,6 @@ Future buildXcodeProject({ DarwinArch? activeArch, bool codesign = true, String? deviceID, - bool isCoreDevice = false, bool configOnly = false, XcodeBuildAction buildAction = XcodeBuildAction.build, }) async { @@ -242,7 +241,6 @@ Future buildXcodeProject({ project: project, targetOverride: targetOverride, buildInfo: buildInfo, - usingCoreDevice: isCoreDevice, ); await processPodsIfNeeded(project.ios, getIosBuildDirectory(), buildInfo.mode); if (configOnly) { diff --git a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart index df53b38695671..8bf662b4b31ce 100644 --- a/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart +++ b/packages/flutter_tools/lib/src/ios/xcode_build_settings.dart @@ -35,7 +35,7 @@ Future updateGeneratedXcodeProperties({ String? targetOverride, bool useMacOSConfig = false, String? buildDirOverride, - bool usingCoreDevice = false, + String? configurationBuildDir, }) async { final List xcodeBuildSettings = await _xcodeBuildSettingsLines( project: project, @@ -43,7 +43,7 @@ Future updateGeneratedXcodeProperties({ targetOverride: targetOverride, useMacOSConfig: useMacOSConfig, buildDirOverride: buildDirOverride, - usingCoreDevice: usingCoreDevice, + configurationBuildDir: configurationBuildDir, ); _updateGeneratedXcodePropertiesFile( @@ -145,7 +145,7 @@ Future> _xcodeBuildSettingsLines({ String? targetOverride, bool useMacOSConfig = false, String? buildDirOverride, - bool usingCoreDevice = false, + String? configurationBuildDir, }) async { final List xcodeBuildSettings = []; @@ -174,9 +174,10 @@ Future> _xcodeBuildSettingsLines({ xcodeBuildSettings.add('FLUTTER_BUILD_NUMBER=$buildNumber'); // CoreDevices in debug and profile mode are launched, but not built, via Xcode. - // Set the BUILD_DIR so Xcode knows where to find the app bundle to launch. - if (usingCoreDevice && !buildInfo.isRelease) { - xcodeBuildSettings.add('BUILD_DIR=${globals.fs.path.absolute(getIosBuildDirectory())}'); + // Set the CONFIGURATION_BUILD_DIR so Xcode knows where to find the app + // bundle to launch. + if (configurationBuildDir != null) { + xcodeBuildSettings.add('CONFIGURATION_BUILD_DIR=$configurationBuildDir'); } final LocalEngineInfo? localEngineInfo = globals.artifacts?.localEngineInfo; diff --git a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart index d7f354cd6148a..00d19d1edeab3 100644 --- a/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/ios_device_start_nonprebuilt_test.dart @@ -519,6 +519,82 @@ void main() { Xcode: () => xcode, }); + testUsingContext('updates Generated.xcconfig before and after launch', () async { + final Completer debugStartedCompleter = Completer(); + final Completer debugEndedCompleter = Completer(); + final IOSDevice iosDevice = setUpIOSDevice( + fileSystem: fileSystem, + processManager: FakeProcessManager.any(), + logger: logger, + artifacts: artifacts, + isCoreDevice: true, + coreDeviceControl: FakeIOSCoreDeviceControl(), + xcodeDebug: FakeXcodeDebug( + expectedProject: XcodeDebugProject( + scheme: 'Runner', + xcodeWorkspace: fileSystem.directory('/ios/Runner.xcworkspace'), + xcodeProject: fileSystem.directory('/ios/Runner.xcodeproj'), + ), + expectedDeviceId: '123', + expectedLaunchArguments: ['--enable-dart-profiling'], + debugStartedCompleter: debugStartedCompleter, + debugEndedCompleter: debugEndedCompleter, + ), + ); + + setUpIOSProject(fileSystem); + final FlutterProject flutterProject = FlutterProject.fromDirectory(fileSystem.currentDirectory); + final BuildableIOSApp buildableIOSApp = BuildableIOSApp(flutterProject.ios, 'flutter', 'My Super Awesome App.app'); + fileSystem.directory('build/ios/Release-iphoneos/My Super Awesome App.app').createSync(recursive: true); + + final FakeDeviceLogReader deviceLogReader = FakeDeviceLogReader(); + + iosDevice.portForwarder = const NoOpDevicePortForwarder(); + iosDevice.setLogReader(buildableIOSApp, deviceLogReader); + + // Start writing messages to the log reader. + Timer.run(() { + deviceLogReader.addLine('Foo'); + deviceLogReader.addLine('The Dart VM service is listening on http://127.0.0.1:456'); + }); + + final Future futureLaunchResult = iosDevice.startApp( + buildableIOSApp, + debuggingOptions: DebuggingOptions.enabled(const BuildInfo( + BuildMode.debug, + null, + buildName: '1.2.3', + buildNumber: '4', + treeShakeIcons: false, + )), + platformArgs: {}, + ); + + await debugStartedCompleter.future; + + // Validate CoreDevice build settings were used + final File config = fileSystem.directory('ios').childFile('Flutter/Generated.xcconfig'); + expect(config.existsSync(), isTrue); + + String contents = config.readAsStringSync(); + expect(contents, contains('CONFIGURATION_BUILD_DIR=/build/ios/iphoneos')); + + debugEndedCompleter.complete(); + + await futureLaunchResult; + + // Validate CoreDevice build settings were removed after launch + contents = config.readAsStringSync(); + expect(contents.contains('CONFIGURATION_BUILD_DIR'), isFalse); + }, overrides: { + ProcessManager: () => FakeProcessManager.any(), + FileSystem: () => fileSystem, + Logger: () => logger, + Platform: () => macPlatform, + XcodeProjectInterpreter: () => fakeXcodeProjectInterpreter, + Xcode: () => xcode, + }); + testUsingContext('fails when Xcode project is not found', () async { final IOSDevice iosDevice = setUpIOSDevice( fileSystem: fileSystem, @@ -750,6 +826,8 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { this.expectedProject, this.expectedDeviceId, this.expectedLaunchArguments, + this.debugStartedCompleter, + this.debugEndedCompleter, }); final bool debugSuccess; @@ -757,6 +835,8 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { final XcodeDebugProject? expectedProject; final String? expectedDeviceId; final List? expectedLaunchArguments; + final Completer? debugStartedCompleter; + final Completer? debugEndedCompleter; @override Future debugApp({ @@ -764,6 +844,7 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { required String deviceId, required List launchArguments, }) async { + debugStartedCompleter?.complete(); if (expectedProject != null) { expect(project.scheme, expectedProject!.scheme); expect(project.xcodeWorkspace.path, expectedProject!.xcodeWorkspace.path); @@ -776,6 +857,7 @@ class FakeXcodeDebug extends Fake implements XcodeDebug { if (expectedLaunchArguments != null) { expect(expectedLaunchArguments, launchArguments); } + await debugEndedCompleter?.future; return debugSuccess; } } diff --git a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart index 4dacb94625ae9..0d5a24ea3c8e7 100644 --- a/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart +++ b/packages/flutter_tools/test/general.shard/ios/xcodeproj_test.dart @@ -1308,66 +1308,41 @@ flutter: }); group('CoreDevice', () { - testUsingContext('sets BUILD_DIR for core devices in debug mode', () async { + testUsingContext('sets CONFIGURATION_BUILD_DIR when configurationBuildDir is set', () async { const BuildInfo buildInfo = BuildInfo.debug; final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); await updateGeneratedXcodeProperties( project: project, buildInfo: buildInfo, - useMacOSConfig: true, - usingCoreDevice: true, + configurationBuildDir: 'path/to/project/build/ios/iphoneos' ); - final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); - expect(config.existsSync(), isTrue); - - final String contents = config.readAsStringSync(); - expect(contents, contains('\nBUILD_DIR=/build/ios\n')); - }, overrides: { - Artifacts: () => localIosArtifacts, - Platform: () => macOS, - FileSystem: () => fs, - ProcessManager: () => FakeProcessManager.any(), - XcodeProjectInterpreter: () => xcodeProjectInterpreter, - }); - - testUsingContext('does not set BUILD_DIR for core devices in release mode', () async { - const BuildInfo buildInfo = BuildInfo.release; - final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); - await updateGeneratedXcodeProperties( - project: project, - buildInfo: buildInfo, - useMacOSConfig: true, - usingCoreDevice: true, - ); - - final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); expect(config.existsSync(), isTrue); final String contents = config.readAsStringSync(); - expect(contents.contains('\nBUILD_DIR'), isFalse); + expect(contents, contains('CONFIGURATION_BUILD_DIR=path/to/project/build/ios/iphoneos')); }, overrides: { Artifacts: () => localIosArtifacts, - Platform: () => macOS, + // Platform: () => macOS, FileSystem: () => fs, ProcessManager: () => FakeProcessManager.any(), XcodeProjectInterpreter: () => xcodeProjectInterpreter, }); - testUsingContext('does not set BUILD_DIR for non core devices', () async { + testUsingContext('does not set CONFIGURATION_BUILD_DIR when configurationBuildDir is not set', () async { const BuildInfo buildInfo = BuildInfo.debug; final FlutterProject project = FlutterProject.fromDirectoryTest(fs.directory('path/to/project')); await updateGeneratedXcodeProperties( project: project, buildInfo: buildInfo, - useMacOSConfig: true, ); - final File config = fs.file('path/to/project/macos/Flutter/ephemeral/Flutter-Generated.xcconfig'); + final File config = fs.file('path/to/project/ios/Flutter/Generated.xcconfig'); expect(config.existsSync(), isTrue); final String contents = config.readAsStringSync(); - expect(contents.contains('\nBUILD_DIR'), isFalse); + expect(contents.contains('CONFIGURATION_BUILD_DIR'), isFalse); }, overrides: { Artifacts: () => localIosArtifacts, Platform: () => macOS, From 12261f987afbd677eb061f103b6628bedab8a6ec Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 13 Sep 2023 14:34:04 -0400 Subject: [PATCH 03/16] Roll Flutter Engine from 5e671d5c90f9 to b71b366e3de3 (4 revisions) (#134676) https://github.com/flutter/engine/compare/5e671d5c90f9...b71b366e3de3 2023-09-13 matanlurey@users.noreply.github.com Do not run real processes in `clang_tidy_test.dart` (flutter/engine#45748) 2023-09-13 30870216+gaaclarke@users.noreply.github.com [Impeller] Adds test to verify wide gamut indexed png decompression fix for Skia. (flutter/engine#45399) 2023-09-13 skia-flutter-autoroll@skia.org Roll Skia from 284c333d7eb2 to 78d18d509475 (2 revisions) (flutter/engine#45769) 2023-09-13 skia-flutter-autoroll@skia.org Roll Skia from 3ff43577d04b to 284c333d7eb2 (1 revision) (flutter/engine#45768) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-engine-flutter-autoroll Please CC bdero@google.com,rmistry@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://bugs.chromium.org/p/skia/issues/entry?template=Autoroller+Bug Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 03f0fa49f0a59..a3510f822f3d3 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -5e671d5c90f9088c7cad498dc8ecfbab8d03336a +b71b366e3de3be6720b193086271a3934ead3901 From 2daf91771778467be384424fb62d843ce1305f10 Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Wed, 13 Sep 2023 12:29:06 -0700 Subject: [PATCH 04/16] [framework] reduce ink sparkle uniform count. (#133897) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes https://github.com/flutter/flutter/issues/133325 Due to the number of uniforms present, the ink_sparkle shader can't run without post processing (on all target platforms) that the impellerc offline compiler doesn't perform. However, we can't just move the uniforms into a uniform buffer object (UBO) because the Skia backend doesn't support it. Rather than work around this in the compiler, we can reduce the uniform count by 1) packing four floats into a single vec4 2) removing a uniform for what is effectively a constant. This should have no visible effects, and if any scubas fail it means I did this wrong 😆 --- .../flutter/lib/src/material/ink_sparkle.dart | 54 ++++++++----------- .../lib/src/material/shaders/ink_sparkle.frag | 37 ++++++------- 2 files changed, 42 insertions(+), 49 deletions(-) diff --git a/packages/flutter/lib/src/material/ink_sparkle.dart b/packages/flutter/lib/src/material/ink_sparkle.dart index a46fd28cd00e2..9f63819fbe16c 100644 --- a/packages/flutter/lib/src/material/ink_sparkle.dart +++ b/packages/flutter/lib/src/material/ink_sparkle.dart @@ -326,50 +326,42 @@ class InkSparkle extends InteractiveInkFeature { ..setFloat(1, _color.green / 255.0) ..setFloat(2, _color.blue / 255.0) ..setFloat(3, _color.alpha / 255.0) - // uAlpha + // Composite 1 (u_alpha, u_sparkle_alpha, u_blur, u_radius_scale) ..setFloat(4, _alpha.value) - // uSparkleColor - ..setFloat(5, 1.0) + ..setFloat(5, _sparkleAlpha.value) ..setFloat(6, 1.0) - ..setFloat(7, 1.0) - ..setFloat(8, 1.0) - // uSparkleAlpha - ..setFloat(9, _sparkleAlpha.value) - // uBlur - ..setFloat(10, 1.0) + ..setFloat(7, _radiusScale.value) // uCenter - ..setFloat(11, _center.value.x) - ..setFloat(12, _center.value.y) - // uRadiusScale - ..setFloat(13, _radiusScale.value) + ..setFloat(8, _center.value.x) + ..setFloat(9, _center.value.y) // uMaxRadius - ..setFloat(14, _targetRadius) + ..setFloat(10, _targetRadius) // uResolutionScale - ..setFloat(15, 1.0 / _width) - ..setFloat(16, 1.0 / _height) + ..setFloat(11, 1.0 / _width) + ..setFloat(12, 1.0 / _height) // uNoiseScale - ..setFloat(17, _noiseDensity / _width) - ..setFloat(18, _noiseDensity / _height) + ..setFloat(13, _noiseDensity / _width) + ..setFloat(14, _noiseDensity / _height) // uNoisePhase - ..setFloat(19, noisePhase / 1000.0) + ..setFloat(15, noisePhase / 1000.0) // uCircle1 - ..setFloat(20, turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.cos(turbulenceScale * 0.55))) - ..setFloat(21, turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.sin(turbulenceScale * 0.55))) + ..setFloat(16, turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.cos(turbulenceScale * 0.55))) + ..setFloat(17, turbulenceScale * 0.5 + (turbulencePhase * 0.01 * math.sin(turbulenceScale * 0.55))) // uCircle2 - ..setFloat(22, turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.45))) - ..setFloat(23, turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.45))) + ..setFloat(18, turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.45))) + ..setFloat(19, turbulenceScale * 0.2 + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.45))) // uCircle3 - ..setFloat(24, turbulenceScale + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.35))) - ..setFloat(25, turbulenceScale + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.35))) + ..setFloat(20, turbulenceScale + (turbulencePhase * -0.0066 * math.cos(turbulenceScale * 0.35))) + ..setFloat(21, turbulenceScale + (turbulencePhase * -0.0066 * math.sin(turbulenceScale * 0.35))) // uRotation1 - ..setFloat(26, math.cos(rotation1)) - ..setFloat(27, math.sin(rotation1)) + ..setFloat(22, math.cos(rotation1)) + ..setFloat(23, math.sin(rotation1)) // uRotation2 - ..setFloat(28, math.cos(rotation2)) - ..setFloat(29, math.sin(rotation2)) + ..setFloat(24, math.cos(rotation2)) + ..setFloat(25, math.sin(rotation2)) // uRotation3 - ..setFloat(30, math.cos(rotation3)) - ..setFloat(31, math.sin(rotation3)); + ..setFloat(26, math.cos(rotation3)) + ..setFloat(27, math.sin(rotation3)); } /// Transforms the canvas for an ink feature to be painted on the [canvas]. diff --git a/packages/flutter/lib/src/material/shaders/ink_sparkle.frag b/packages/flutter/lib/src/material/shaders/ink_sparkle.frag index aa82583921fbb..f9f3ce6be3dd5 100644 --- a/packages/flutter/lib/src/material/shaders/ink_sparkle.frag +++ b/packages/flutter/lib/src/material/shaders/ink_sparkle.frag @@ -11,22 +11,19 @@ precision highp float; // TODO(antrob): Put these in a more logical order (e.g. separate consts vs varying, etc) layout(location = 0) uniform vec4 u_color; -layout(location = 1) uniform float u_alpha; -layout(location = 2) uniform vec4 u_sparkle_color; -layout(location = 3) uniform float u_sparkle_alpha; -layout(location = 4) uniform float u_blur; -layout(location = 5) uniform vec2 u_center; -layout(location = 6) uniform float u_radius_scale; -layout(location = 7) uniform float u_max_radius; -layout(location = 8) uniform vec2 u_resolution_scale; -layout(location = 9) uniform vec2 u_noise_scale; -layout(location = 10) uniform float u_noise_phase; -layout(location = 11) uniform vec2 u_circle1; -layout(location = 12) uniform vec2 u_circle2; -layout(location = 13) uniform vec2 u_circle3; -layout(location = 14) uniform vec2 u_rotation1; -layout(location = 15) uniform vec2 u_rotation2; -layout(location = 16) uniform vec2 u_rotation3; +// u_alpha, u_sparkle_alpha, u_blur, u_radius_scale +layout(location = 1) uniform vec4 u_composite_1; +layout(location = 2) uniform vec2 u_center; +layout(location = 3) uniform float u_max_radius; +layout(location = 4) uniform vec2 u_resolution_scale; +layout(location = 5) uniform vec2 u_noise_scale; +layout(location = 6) uniform float u_noise_phase; +layout(location = 7) uniform vec2 u_circle1; +layout(location = 8) uniform vec2 u_circle2; +layout(location = 9) uniform vec2 u_circle3; +layout(location = 10) uniform vec2 u_rotation1; +layout(location = 11) uniform vec2 u_rotation2; +layout(location = 12) uniform vec2 u_rotation3; layout(location = 0) out vec4 fragColor; @@ -36,6 +33,11 @@ const float PI_ROTATE_LEFT = PI * -0.0078125; const float ONE_THIRD = 1./3.; const vec2 TURBULENCE_SCALE = vec2(0.8); +float u_alpha = u_composite_1.x; +float u_sparkle_alpha = u_composite_1.y; +float u_blur = u_composite_1.z; +float u_radius_scale = u_composite_1.w; + float triangle_noise(highp vec2 n) { n = fract(n * vec2(5.3987, 5.4421)); n += dot(n.yx, n.xy + vec2(21.5351, 14.3137)); @@ -99,6 +101,5 @@ void main() { float sparkle = sparkle(density_uv, u_noise_phase) * ring * turbulence * u_sparkle_alpha; float wave_alpha = soft_circle(p, u_center, radius, u_blur) * u_alpha * u_color.a; vec4 wave_color = vec4(u_color.rgb * wave_alpha, wave_alpha); - vec4 sparkle_color = vec4(u_sparkle_color.rgb * u_sparkle_color.a, u_sparkle_color.a); - fragColor = mix(wave_color, sparkle_color, sparkle); + fragColor = mix(wave_color, vec4(1.0), sparkle); } From ab1b865e58a69e7f6c2f3566532b5bbc838d678d Mon Sep 17 00:00:00 2001 From: hangyu Date: Wed, 13 Sep 2023 12:36:51 -0700 Subject: [PATCH 05/16] Dispose routes in navigator when throwing exception (#134596) fixes: #133695 ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/wiki/Tree-hygiene#overview [Tree Hygiene]: https://github.com/flutter/flutter/wiki/Tree-hygiene [test-exempt]: https://github.com/flutter/flutter/wiki/Tree-hygiene#tests [Flutter Style Guide]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo [Features we expect every widget to implement]: https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/wiki/Tree-hygiene#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/wiki/Chat --- packages/flutter/lib/src/widgets/navigator.dart | 3 +++ packages/flutter/test/material/app_test.dart | 10 +--------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/flutter/lib/src/widgets/navigator.dart b/packages/flutter/lib/src/widgets/navigator.dart index ccf3fbfb56ad9..e2f350d299b0e 100644 --- a/packages/flutter/lib/src/widgets/navigator.dart +++ b/packages/flutter/lib/src/widgets/navigator.dart @@ -2792,6 +2792,9 @@ class Navigator extends StatefulWidget { ); return true; }()); + for (final Route? route in result) { + route?.dispose(); + } result.clear(); } } else if (initialRouteName != Navigator.defaultRouteName) { diff --git a/packages/flutter/test/material/app_test.dart b/packages/flutter/test/material/app_test.dart index 16bbac3557fcb..31d86997a8815 100644 --- a/packages/flutter/test/material/app_test.dart +++ b/packages/flutter/test/material/app_test.dart @@ -334,15 +334,7 @@ void main() { expect(find.text('route "/a/b"'), findsNothing); expect(find.text('route "/b"'), findsNothing); } - }, - // TODO(polina-c): remove after fixing - // https://github.com/flutter/flutter/issues/133695 - leakTrackingTestConfig: const LeakTrackingTestConfig( - notDisposedAllowList: { - 'ValueNotifier': 3, - 'MaterialPageRoute': 3, - }, - )); + }); testWidgetsWithLeakTracking('Make sure initialRoute is only used the first time', (WidgetTester tester) async { final Map routes = { From 3d7cd3594a12dacf2ddf45f630bb4bcc401e6f17 Mon Sep 17 00:00:00 2001 From: Christopher Fujino Date: Wed, 13 Sep 2023 13:05:29 -0700 Subject: [PATCH 06/16] [flutter_tools] Run ShutdownHooks when handling signals (#134590) Fixes https://github.com/flutter/flutter/issues/134566. Prior to this fix, `ShutdownHooks` were run in the private helper function `_exit()` defined in the `package:flutter_tools/runner.dart` library. Independent of this, the tool had signal handling logic that traps SIGINT and SIGTERM. However, these handlers called `exit()` from `dart:io`, and didn't run these hooks. This PR moves the `_exit()` private helper to `package:flutter_tools/src/base/process.dart` and renames it to `exitWithHooks()`, so that it can be called by the signal handlers in `package:flutter_tools/src/base/signals.dart`. --- packages/flutter_tools/lib/runner.dart | 86 ++----------------- .../flutter_tools/lib/src/base/process.dart | 75 ++++++++++++++++ .../flutter_tools/lib/src/base/signals.dart | 13 ++- .../test/general.shard/base/signals_test.dart | 40 ++++++++- 4 files changed, 129 insertions(+), 85 deletions(-) diff --git a/packages/flutter_tools/lib/runner.dart b/packages/flutter_tools/lib/runner.dart index 9b33580af03e5..262eed522926b 100644 --- a/packages/flutter_tools/lib/runner.dart +++ b/packages/flutter_tools/lib/runner.dart @@ -20,7 +20,6 @@ import 'src/context_runner.dart'; import 'src/doctor.dart'; import 'src/globals.dart' as globals; import 'src/reporting/crash_reporting.dart'; -import 'src/reporting/first_run.dart'; import 'src/reporting/reporting.dart'; import 'src/runner/flutter_command.dart'; import 'src/runner/flutter_command_runner.dart'; @@ -115,7 +114,7 @@ Future run( // Triggering [runZoned]'s error callback does not necessarily mean that // we stopped executing the body. See https://github.com/dart-lang/sdk/issues/42150. if (firstError == null) { - return await _exit(0, shutdownHooks: shutdownHooks); + return await exitWithHooks(0, shutdownHooks: shutdownHooks); } // We already hit some error, so don't return success. The error path @@ -151,7 +150,7 @@ Future _handleToolError( globals.printError('${error.message}\n'); globals.printError("Run 'flutter -h' (or 'flutter -h') for available flutter commands and options."); // Argument error exit code. - return _exit(64, shutdownHooks: shutdownHooks); + return exitWithHooks(64, shutdownHooks: shutdownHooks); } else if (error is ToolExit) { if (error.message != null) { globals.printError(error.message!); @@ -159,14 +158,14 @@ Future _handleToolError( if (verbose) { globals.printError('\n$stackTrace\n'); } - return _exit(error.exitCode ?? 1, shutdownHooks: shutdownHooks); + return exitWithHooks(error.exitCode ?? 1, shutdownHooks: shutdownHooks); } else if (error is ProcessExit) { // We've caught an exit code. if (error.immediate) { exit(error.exitCode); return error.exitCode; } else { - return _exit(error.exitCode, shutdownHooks: shutdownHooks); + return exitWithHooks(error.exitCode, shutdownHooks: shutdownHooks); } } else { // We've crashed; emit a log report. @@ -176,7 +175,7 @@ Future _handleToolError( // Print the stack trace on the bots - don't write a crash report. globals.stdio.stderrWrite('$error\n'); globals.stdio.stderrWrite('$stackTrace\n'); - return _exit(1, shutdownHooks: shutdownHooks); + return exitWithHooks(1, shutdownHooks: shutdownHooks); } // Report to both [Usage] and [CrashReportSender]. @@ -217,7 +216,7 @@ Future _handleToolError( final File file = await _createLocalCrashReport(details); await globals.crashReporter!.informUser(details, file); - return _exit(1, shutdownHooks: shutdownHooks); + return exitWithHooks(1, shutdownHooks: shutdownHooks); // This catch catches all exceptions to ensure the message below is printed. } catch (error, st) { // ignore: avoid_catches_without_on_clauses globals.stdio.stderrWrite( @@ -283,76 +282,3 @@ Future _createLocalCrashReport(CrashDetails details) async { return crashFile; } - -Future _exit(int code, {required ShutdownHooks shutdownHooks}) async { - // Need to get the boolean returned from `messenger.shouldDisplayLicenseTerms()` - // before invoking the print welcome method because the print welcome method - // will set `messenger.shouldDisplayLicenseTerms()` to false - final FirstRunMessenger messenger = - FirstRunMessenger(persistentToolState: globals.persistentToolState!); - final bool legacyAnalyticsMessageShown = - messenger.shouldDisplayLicenseTerms(); - - // Prints the welcome message if needed for legacy analytics. - globals.flutterUsage.printWelcome(); - - // Ensure that the consent message has been displayed for unified analytics - if (globals.analytics.shouldShowMessage) { - globals.logger.printStatus(globals.analytics.getConsentMessage); - if (!globals.flutterUsage.enabled) { - globals.printStatus( - 'Please note that analytics reporting was already disabled, ' - 'and will continue to be disabled.\n'); - } - - // Because the legacy analytics may have also sent a message, - // the conditional below will print additional messaging informing - // users that the two consent messages they are receiving is not a - // bug - if (legacyAnalyticsMessageShown) { - globals.logger - .printStatus('You have received two consent messages because ' - 'the flutter tool is migrating to a new analytics system. ' - 'Disabling analytics collection will disable both the legacy ' - 'and new analytics collection systems. ' - 'You can disable analytics reporting by running `flutter --disable-analytics`\n'); - } - - // Invoking this will onboard the flutter tool onto - // the package on the developer's machine and will - // allow for events to be sent to Google Analytics - // on subsequent runs of the flutter tool (ie. no events - // will be sent on the first run to allow developers to - // opt out of collection) - globals.analytics.clientShowedMessage(); - } - - // Send any last analytics calls that are in progress without overly delaying - // the tool's exit (we wait a maximum of 250ms). - if (globals.flutterUsage.enabled) { - final Stopwatch stopwatch = Stopwatch()..start(); - await globals.flutterUsage.ensureAnalyticsSent(); - globals.printTrace('ensureAnalyticsSent: ${stopwatch.elapsedMilliseconds}ms'); - } - - // Run shutdown hooks before flushing logs - await shutdownHooks.runShutdownHooks(globals.logger); - - final Completer completer = Completer(); - - // Give the task / timer queue one cycle through before we hard exit. - Timer.run(() { - try { - globals.printTrace('exiting with code $code'); - exit(code); - completer.complete(); - // This catches all exceptions because the error is propagated on the - // completer. - } catch (error, stackTrace) { // ignore: avoid_catches_without_on_clauses - completer.completeError(error, stackTrace); - } - }); - - await completer.future; - return code; -} diff --git a/packages/flutter_tools/lib/src/base/process.dart b/packages/flutter_tools/lib/src/base/process.dart index 82a3c89b1d919..6fb36b0bc046a 100644 --- a/packages/flutter_tools/lib/src/base/process.dart +++ b/packages/flutter_tools/lib/src/base/process.dart @@ -8,6 +8,8 @@ import 'package:meta/meta.dart'; import 'package:process/process.dart'; import '../convert.dart'; +import '../globals.dart' as globals; +import '../reporting/first_run.dart'; import 'io.dart'; import 'logger.dart'; @@ -564,3 +566,76 @@ class _DefaultProcessUtils implements ProcessUtils { } } } + +Future exitWithHooks(int code, {required ShutdownHooks shutdownHooks}) async { + // Need to get the boolean returned from `messenger.shouldDisplayLicenseTerms()` + // before invoking the print welcome method because the print welcome method + // will set `messenger.shouldDisplayLicenseTerms()` to false + final FirstRunMessenger messenger = + FirstRunMessenger(persistentToolState: globals.persistentToolState!); + final bool legacyAnalyticsMessageShown = + messenger.shouldDisplayLicenseTerms(); + + // Prints the welcome message if needed for legacy analytics. + globals.flutterUsage.printWelcome(); + + // Ensure that the consent message has been displayed for unified analytics + if (globals.analytics.shouldShowMessage) { + globals.logger.printStatus(globals.analytics.getConsentMessage); + if (!globals.flutterUsage.enabled) { + globals.printStatus( + 'Please note that analytics reporting was already disabled, ' + 'and will continue to be disabled.\n'); + } + + // Because the legacy analytics may have also sent a message, + // the conditional below will print additional messaging informing + // users that the two consent messages they are receiving is not a + // bug + if (legacyAnalyticsMessageShown) { + globals.logger + .printStatus('You have received two consent messages because ' + 'the flutter tool is migrating to a new analytics system. ' + 'Disabling analytics collection will disable both the legacy ' + 'and new analytics collection systems. ' + 'You can disable analytics reporting by running `flutter --disable-analytics`\n'); + } + + // Invoking this will onboard the flutter tool onto + // the package on the developer's machine and will + // allow for events to be sent to Google Analytics + // on subsequent runs of the flutter tool (ie. no events + // will be sent on the first run to allow developers to + // opt out of collection) + globals.analytics.clientShowedMessage(); + } + + // Send any last analytics calls that are in progress without overly delaying + // the tool's exit (we wait a maximum of 250ms). + if (globals.flutterUsage.enabled) { + final Stopwatch stopwatch = Stopwatch()..start(); + await globals.flutterUsage.ensureAnalyticsSent(); + globals.printTrace('ensureAnalyticsSent: ${stopwatch.elapsedMilliseconds}ms'); + } + + // Run shutdown hooks before flushing logs + await shutdownHooks.runShutdownHooks(globals.logger); + + final Completer completer = Completer(); + + // Give the task / timer queue one cycle through before we hard exit. + Timer.run(() { + try { + globals.printTrace('exiting with code $code'); + exit(code); + completer.complete(); + // This catches all exceptions because the error is propagated on the + // completer. + } catch (error, stackTrace) { // ignore: avoid_catches_without_on_clauses + completer.completeError(error, stackTrace); + } + }); + + await completer.future; + return code; +} diff --git a/packages/flutter_tools/lib/src/base/signals.dart b/packages/flutter_tools/lib/src/base/signals.dart index 9185762cdfe54..a83d85b622e86 100644 --- a/packages/flutter_tools/lib/src/base/signals.dart +++ b/packages/flutter_tools/lib/src/base/signals.dart @@ -6,6 +6,8 @@ import 'dart:async'; import 'package:meta/meta.dart'; +import '../base/process.dart'; +import '../globals.dart' as globals; import 'async_guard.dart'; import 'io.dart'; @@ -18,7 +20,8 @@ abstract class Signals { @visibleForTesting factory Signals.test({ List exitSignals = defaultExitSignals, - }) => LocalSignals._(exitSignals); + ShutdownHooks? shutdownHooks, + }) => LocalSignals._(exitSignals, shutdownHooks: shutdownHooks); // The default list of signals that should cause the process to exit. static const List defaultExitSignals = [ @@ -50,13 +53,17 @@ abstract class Signals { /// We use a singleton instance of this class to ensure that all handlers for /// fatal signals run before this class calls exit(). class LocalSignals implements Signals { - LocalSignals._(this.exitSignals); + LocalSignals._( + this.exitSignals, { + ShutdownHooks? shutdownHooks, + }) : _shutdownHooks = shutdownHooks ?? globals.shutdownHooks; static LocalSignals instance = LocalSignals._( Signals.defaultExitSignals, ); final List exitSignals; + final ShutdownHooks _shutdownHooks; // A table mapping (signal, token) -> signal handler. final Map> _handlersTable = @@ -144,7 +151,7 @@ class LocalSignals implements Signals { // If this was a signal that should cause the process to go down, then // call exit(); if (_shouldExitFor(s)) { - exit(0); + await exitWithHooks(0, shutdownHooks: _shutdownHooks); } } diff --git a/packages/flutter_tools/test/general.shard/base/signals_test.dart b/packages/flutter_tools/test/general.shard/base/signals_test.dart index 0d8cae24c1830..d2b321b102c4f 100644 --- a/packages/flutter_tools/test/general.shard/base/signals_test.dart +++ b/packages/flutter_tools/test/general.shard/base/signals_test.dart @@ -6,19 +6,24 @@ import 'dart:async'; import 'dart:io' as io; import 'package:flutter_tools/src/base/io.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/process.dart'; import 'package:flutter_tools/src/base/signals.dart'; import 'package:test/fake.dart'; import '../../src/common.dart'; +import '../../src/context.dart'; void main() { group('Signals', () { late Signals signals; late FakeProcessSignal fakeSignal; late ProcessSignal signalUnderTest; + late FakeShutdownHooks shutdownHooks; setUp(() { - signals = Signals.test(); + shutdownHooks = FakeShutdownHooks(); + signals = Signals.test(shutdownHooks: shutdownHooks); fakeSignal = FakeProcessSignal(); signalUnderTest = ProcessSignal(fakeSignal); }); @@ -168,9 +173,10 @@ void main() { expect(errList, isEmpty); }); - testWithoutContext('all handlers for exiting signals are run before exit', () async { + testUsingContext('all handlers for exiting signals are run before exit', () async { final Signals signals = Signals.test( exitSignals: [signalUnderTest], + shutdownHooks: shutdownHooks, ); final Completer completer = Completer(); bool first = false; @@ -201,6 +207,27 @@ void main() { fakeSignal.controller.add(fakeSignal); await completer.future; + expect(shutdownHooks.ranShutdownHooks, isTrue); + }); + + testUsingContext('ShutdownHooks run before exiting', () async { + final Signals signals = Signals.test( + exitSignals: [signalUnderTest], + shutdownHooks: shutdownHooks, + ); + final Completer completer = Completer(); + + setExitFunctionForTests((int exitCode) { + expect(exitCode, 0); + restoreExitFunction(); + completer.complete(); + }); + + signals.addHandler(signalUnderTest, (ProcessSignal s) {}); + + fakeSignal.controller.add(fakeSignal); + await completer.future; + expect(shutdownHooks.ranShutdownHooks, isTrue); }); }); } @@ -211,3 +238,12 @@ class FakeProcessSignal extends Fake implements io.ProcessSignal { @override Stream watch() => controller.stream; } + +class FakeShutdownHooks extends Fake implements ShutdownHooks { + bool ranShutdownHooks = false; + + @override + Future runShutdownHooks(Logger logger) async { + ranShutdownHooks = true; + } +} From 5243d18e8db19b29cf4c681256cdae7d525cc80d Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 13 Sep 2023 16:33:16 -0400 Subject: [PATCH 07/16] Roll Flutter Engine from b71b366e3de3 to 154d6fd601a3 (6 revisions) (#134683) https://github.com/flutter/engine/compare/b71b366e3de3...154d6fd601a3 2023-09-13 godofredoc@google.com Auto update dependencies for web_ui. (flutter/engine#45754) 2023-09-13 30870216+gaaclarke@users.noreply.github.com Revert "[Impeller] Patch the compiler to account for subpass inputs and PSO metadata." (flutter/engine#45777) 2023-09-13 skia-flutter-autoroll@skia.org Roll Skia from 471216072c74 to e39cf360ea93 (4 revisions) (flutter/engine#45776) 2023-09-13 chinmaygarde@google.com [Impeller] Remove dependency on //flutter/vulkan and use a single proc table. (flutter/engine#45741) 2023-09-13 skia-flutter-autoroll@skia.org Roll Skia from 78d18d509475 to 471216072c74 (4 revisions) (flutter/engine#45774) 2023-09-13 47866232+chunhtai@users.noreply.github.com [Impeller] Fixes stroke path geometry that can draw outside of path if the path ends at sharp turn. (flutter/engine#45252) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-engine-flutter-autoroll Please CC bdero@google.com,rmistry@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://bugs.chromium.org/p/skia/issues/entry?template=Autoroller+Bug Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index a3510f822f3d3..112fb747993e5 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -b71b366e3de3be6720b193086271a3934ead3901 +154d6fd601a3442559e430f8fe197a58ac99867a From 04ad1da1ae167c42ae8ba484758036ef9e50ddc9 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 22:28:23 +0000 Subject: [PATCH 08/16] Bump github/codeql-action from 2.21.5 to 2.21.6 (#134692) Bumps [github/codeql-action](https://github.com/github/codeql-action) from 2.21.5 to 2.21.6.
Changelog

Sourced from github/codeql-action's changelog.

CodeQL Action Changelog

See the releases page for the relevant changes to the CodeQL CLI and language packs.

[UNRELEASED]

No user facing changes.

2.21.6 - 13 Sep 2023

  • Better error message when there is a failure to determine the merge base of the code to analysis. #1860
  • Improve the calculation of default amount of RAM used for query execution on GitHub Enterprise Server. This now reduces in proportion to the runner's total memory to better account for system memory usage, helping to avoid out-of-memory failures on larger runners. This feature is already available to GitHub.com users. #1866
  • Enable improved file coverage information for GitHub Enterprise Server users. This feature is already available to GitHub.com users. #1867
  • Update default CodeQL bundle version to 2.14.4. #1873

2.21.5 - 28 Aug 2023

  • Update default CodeQL bundle version to 2.14.3. #1845
  • Fixed a bug in CodeQL Action 2.21.3 onwards that affected beta support for Project Lombok when analyzing Java. The environment variable CODEQL_EXTRACTOR_JAVA_RUN_ANNOTATION_PROCESSORS will now be respected if it was manually configured in the workflow. #1844
  • Enable support for Kotlin 1.9.20 when running with CodeQL CLI v2.13.4 through v2.14.3. #1853

2.21.4 - 14 Aug 2023

  • Update default CodeQL bundle version to 2.14.2. #1831
  • Log a warning if the amount of available disk space runs low during a code scanning run. #1825
  • When downloading CodeQL bundle version 2.13.4 and later, cache these bundles in the Actions tool cache using a simpler version number. #1832
  • Fix an issue that first appeared in CodeQL Action v2.21.2 that prevented CodeQL invocations from being logged. #1833
  • We are rolling out a feature in August 2023 that will improve the quality of file coverage information. #1835

2.21.3 - 08 Aug 2023

  • We are rolling out a feature in August 2023 that will improve multi-threaded performance on larger runners. #1817
  • We are rolling out a feature in August 2023 that adds beta support for Project Lombok when analyzing Java. #1809
  • Reduce disk space usage when downloading the CodeQL bundle. #1820

2.21.2 - 28 Jul 2023

  • Update default CodeQL bundle version to 2.14.1. #1797
  • Avoid duplicating the analysis summary within the logs. #1811

2.21.1 - 26 Jul 2023

  • Improve the handling of fatal errors from the CodeQL CLI. #1795
  • Add the sarif-output output to the analyze action that contains the path to the directory of the generated SARIF. #1799

2.21.0 - 19 Jul 2023

  • CodeQL Action now requires CodeQL CLI 2.9.4 or later. For more information, see the corresponding changelog entry for CodeQL Action version 2.20.4. #1724

2.20.4 - 14 Jul 2023

... (truncated)

Commits
  • 701f152 Merge pull request #1875 from github/update-v2.21.6-6a6a82470
  • 1b62990 Fix misplaced changelog entry
  • 5462f69 Update changelog for v2.21.6
  • 6a6a824 Merge pull request #1873 from github/update-bundle/codeql-bundle-v2.14.4
  • 88c7a5c Add changelog note
  • da65035 Update default bundle to codeql-bundle-v2.14.4
  • 43750fe Merge pull request #1872 from github/henrymercer/user-errors-for-upload-sarif
  • a7c12a5 Address PR comments
  • 7218de5 Merge branch 'main' into henrymercer/user-errors-for-upload-sarif
  • 4764dce Merge pull request #1866 from github/henrymercer/enable-scaling-reserved-ram-...
  • Additional commits viewable in compare view

[![Dependabot compatibility score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=github/codeql-action&package-manager=github_actions&previous-version=2.21.5&new-version=2.21.6)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores) Dependabot will resolve any conflicts with this PR as long as you don't alter it yourself. You can also trigger a rebase manually by commenting `@dependabot rebase`. ---
Dependabot commands and options
You can trigger Dependabot actions by commenting on this PR: - `@dependabot rebase` will rebase this PR - `@dependabot recreate` will recreate this PR, overwriting any edits that have been made to it - `@dependabot merge` will merge this PR after your CI passes on it - `@dependabot squash and merge` will squash and merge this PR after your CI passes on it - `@dependabot cancel merge` will cancel a previously requested merge and block automerging - `@dependabot reopen` will reopen this PR if it is closed - `@dependabot close` will close this PR and stop Dependabot recreating it. You can achieve the same result by closing it manually - `@dependabot show ignore conditions` will show all of the ignore conditions of the specified dependency - `@dependabot ignore this major version` will close this PR and stop Dependabot creating any more for this major version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this minor version` will close this PR and stop Dependabot creating any more for this minor version (unless you reopen the PR or upgrade to it yourself) - `@dependabot ignore this dependency` will close this PR and stop Dependabot creating any more for this dependency (unless you reopen the PR or upgrade to it yourself)
--- .github/workflows/scorecards-analysis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/scorecards-analysis.yml b/.github/workflows/scorecards-analysis.yml index 74e214d4e84ca..9ee5be88b630b 100644 --- a/.github/workflows/scorecards-analysis.yml +++ b/.github/workflows/scorecards-analysis.yml @@ -51,6 +51,6 @@ jobs: # Upload the results to GitHub's code scanning dashboard. - name: "Upload to code-scanning" - uses: github/codeql-action/upload-sarif@00e563ead9f72a8461b24876bee2d0c2e8bd2ee8 + uses: github/codeql-action/upload-sarif@701f152f28d4350ad289a5e31435e9ab6169a7ca with: sarif_file: results.sarif From a80af67a0030dbc4ee5a524384e0b5e20853fb4c Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Wed, 13 Sep 2023 18:30:08 -0400 Subject: [PATCH 09/16] Roll Flutter Engine from 154d6fd601a3 to cd90cc8469fb (3 revisions) (#134691) https://github.com/flutter/engine/compare/154d6fd601a3...cd90cc8469fb 2023-09-13 godofredoc@google.com Update dependabot.yml (flutter/engine#45788) 2023-09-13 skia-flutter-autoroll@skia.org Roll Skia from e39cf360ea93 to b38989859b81 (4 revisions) (flutter/engine#45787) 2023-09-13 109111084+yaakovschectman@users.noreply.github.com Use `start` instead of `extent` for Windows IME cursor position (flutter/engine#45667) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-engine-flutter-autoroll Please CC bdero@google.com,rmistry@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://bugs.chromium.org/p/skia/issues/entry?template=Autoroller+Bug Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- bin/internal/engine.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/engine.version b/bin/internal/engine.version index 112fb747993e5..c7f0a1d004853 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -154d6fd601a3442559e430f8fe197a58ac99867a +cd90cc8469fbc789e0b44adc79f330f8d4d2744c From b4953c37694e7c8ab0e4daa15c4ab6deb97b7fde Mon Sep 17 00:00:00 2001 From: Delwin Mathew <84124091+opxdelwin@users.noreply.github.com> Date: Thu, 14 Sep 2023 04:25:05 +0530 Subject: [PATCH 10/16] Fix null check crash by ReorderableList (#132153) Fix issue where if you drag the last element of `ReorderableList` and put it in the same index, a `Null check` error arises. This happens as index in `_items` is out of bounds (when `reverse: true`). Fix is to check if last element, dragged element and drop index is same, and return as nothing has changed. Find this video attached. https://github.com/flutter/flutter/assets/84124091/8043cac3-eb08-42e1-87e7-8095ecab09dc Fixes issue #132077 --- .../lib/src/widgets/reorderable_list.dart | 19 ++++++++ .../test/widgets/reorderable_list_test.dart | 45 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/packages/flutter/lib/src/widgets/reorderable_list.dart b/packages/flutter/lib/src/widgets/reorderable_list.dart index 0f0d5e78ccec3..af912e5ac2c13 100644 --- a/packages/flutter/lib/src/widgets/reorderable_list.dart +++ b/packages/flutter/lib/src/widgets/reorderable_list.dart @@ -798,6 +798,25 @@ class SliverReorderableListState extends State with Ticke } void _dragEnd(_DragInfo item) { + // No changes required if last child is being inserted into the last position. + if ((_insertIndex! + 1 == _items.length) && _reverse) { + final RenderBox lastItemRenderBox = _items[_items.length - 1]!.context.findRenderObject()! as RenderBox; + final Offset lastItemOffset = lastItemRenderBox.localToGlobal(Offset.zero); + + // When drag starts, the corresponding element is removed from + // the list, and moves inside of [ReorderableListState.CustomScrollView], + // which gives [CustomScrollView] a variable height. + // + // So when the element is moved, delta would change accordingly, + // and since it's the last element, + // we animate it back to it's position and add it back to the list. + final double delta = item.itemSize.height; + + setState(() { + _finalDropPosition = Offset(lastItemOffset.dx, lastItemOffset.dy - delta); + }); + return; + } setState(() { if (_insertIndex == item.index) { _finalDropPosition = _itemOffsetAt(_insertIndex! + (_reverse ? 1 : 0)); diff --git a/packages/flutter/test/widgets/reorderable_list_test.dart b/packages/flutter/test/widgets/reorderable_list_test.dart index 1437c597109fa..496d2242b1b33 100644 --- a/packages/flutter/test/widgets/reorderable_list_test.dart +++ b/packages/flutter/test/widgets/reorderable_list_test.dart @@ -1309,6 +1309,51 @@ void main() { expect(offsetForFastScroller / offsetForSlowScroller, fastVelocityScalar / slowVelocityScalar); }); + + testWidgets('Null check error when dragging and dropping last element into last index with reverse:true', (WidgetTester tester) async { + // Regression test for https://github.com/flutter/flutter/issues/132077 + const int itemCount = 5; + final List items = List.generate(itemCount, (int index) => 'Item ${index+1}'); + + await tester.pumpWidget( + MaterialApp( + home: ReorderableList( + onReorder: (int oldIndex, int newIndex) { + if (newIndex > oldIndex) { + newIndex -= 1; + } + final String item = items.removeAt(oldIndex); + items.insert(newIndex, item); + }, + itemCount: items.length, + reverse: true, + itemBuilder: (BuildContext context, int index) { + return ReorderableDragStartListener( + key: Key('$index'), + index: index, + child: Material( + child: ListTile( + title: Text(items[index]), + ), + ), + ); + }, + ), + ) + ); + + // Start gesture on last item + final TestGesture drag = await tester.startGesture(tester.getCenter(find.text('Item 5'))); + await tester.pump(kLongPressTimeout); + + // Drag to move up the last item, and drop at the last index + await drag.moveBy(const Offset(0, -50)); + await tester.pump(); + await drag.up(); + await tester.pumpAndSettle(); + + expect(tester.takeException(), null); + }); } class TestList extends StatelessWidget { From 2ea9edc1ad51c742d4f3c55610820a33456d2b62 Mon Sep 17 00:00:00 2001 From: Kate Lovett Date: Wed, 13 Sep 2023 18:16:14 -0500 Subject: [PATCH 11/16] Update KeepAlive.debugTypicalAncestorWidgetClass (#133498) --- .../flutter/lib/src/cupertino/dialog.dart | 3 +-- .../flutter/lib/src/widgets/framework.dart | 26 ++++++++++++++----- packages/flutter/lib/src/widgets/sliver.dart | 12 ++++++--- .../flutter/test/widgets/keep_alive_test.dart | 8 ++++++ 4 files changed, 38 insertions(+), 11 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index 2e4d27af75740..4b969a2b3146e 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -1577,8 +1577,7 @@ class _ActionButtonParentDataWidget } @override - Type get debugTypicalAncestorWidgetClass => - _CupertinoDialogActionsRenderWidget; + Type get debugTypicalAncestorWidgetClass => _CupertinoDialogActionsRenderWidget; } // ParentData applied to individual action buttons that report whether or not diff --git a/packages/flutter/lib/src/widgets/framework.dart b/packages/flutter/lib/src/widgets/framework.dart index 34421ae2e5efe..ebe4e60e849cd 100644 --- a/packages/flutter/lib/src/widgets/framework.dart +++ b/packages/flutter/lib/src/widgets/framework.dart @@ -1592,15 +1592,19 @@ abstract class ParentDataWidget extends ProxyWidget { return renderObject.parentData is T; } - /// The [RenderObjectWidget] that is typically used to set up the [ParentData] - /// that [applyParentData] will write to. + /// Describes the [RenderObjectWidget] that is typically used to set up the + /// [ParentData] that [applyParentData] will write to. /// /// This is only used in error messages to tell users what widget typically - /// wraps this [ParentDataWidget]. + /// wraps this [ParentDataWidget] through + /// [debugTypicalAncestorWidgetDescription]. /// /// ## Implementations /// - /// The returned type should be a subclass of `RenderObjectWidget`. + /// The returned Type should describe a subclass of `RenderObjectWidget`. If + /// more than one Type is supported, use + /// [debugTypicalAncestorWidgetDescription], which typically inserts this + /// value but can be overridden to describe more than one Type. /// /// ```dart /// @override @@ -1612,6 +1616,16 @@ abstract class ParentDataWidget extends ProxyWidget { /// type is specialized), or specifying the upper bound (e.g. `Foo`). Type get debugTypicalAncestorWidgetClass; + /// Describes the [RenderObjectWidget] that is typically used to set up the + /// [ParentData] that [applyParentData] will write to. + /// + /// This is only used in error messages to tell users what widget typically + /// wraps this [ParentDataWidget]. + /// + /// Returns [debugTypicalAncestorWidgetClass] by default as a String. This can + /// be overridden to describe more than one Type of valid parent. + String get debugTypicalAncestorWidgetDescription => '$debugTypicalAncestorWidgetClass'; + Iterable _debugDescribeIncorrectParentDataType({ required ParentData? parentData, RenderObjectWidget? parentDataCreator, @@ -1632,7 +1646,7 @@ abstract class ParentDataWidget extends ProxyWidget { ), ErrorHint( 'Usually, this means that the $runtimeType widget has the wrong ancestor RenderObjectWidget. ' - 'Typically, $runtimeType widgets are placed directly inside $debugTypicalAncestorWidgetClass widgets.', + 'Typically, $runtimeType widgets are placed directly inside $debugTypicalAncestorWidgetDescription widgets.', ), if (parentDataCreator != null) ErrorHint( @@ -6300,7 +6314,7 @@ abstract class RenderObjectElement extends Element { ErrorSummary('Incorrect use of ParentDataWidget.'), ErrorDescription('The following ParentDataWidgets are providing parent data to the same RenderObject:'), for (final ParentDataElement ancestor in badAncestors) - ErrorDescription('- ${ancestor.widget} (typically placed directly inside a ${(ancestor.widget as ParentDataWidget).debugTypicalAncestorWidgetClass} widget)'), + ErrorDescription('- ${ancestor.widget} (typically placed directly inside a ${(ancestor.widget as ParentDataWidget).debugTypicalAncestorWidgetDescription} widget)'), ErrorDescription('However, a RenderObject can only receive parent data from at most one ParentDataWidget.'), ErrorHint('Usually, this indicates that at least one of the offending ParentDataWidgets listed above is not placed directly inside a compatible ancestor widget.'), ErrorDescription('The ownership chain for the RenderObject that received the parent data was:\n ${debugGetCreatorChain(10)}'), diff --git a/packages/flutter/lib/src/widgets/sliver.dart b/packages/flutter/lib/src/widgets/sliver.dart index 65ce9e48d76fe..f4bb14da73cc3 100644 --- a/packages/flutter/lib/src/widgets/sliver.dart +++ b/packages/flutter/lib/src/widgets/sliver.dart @@ -1311,8 +1311,8 @@ class _SliverOffstageElement extends SingleChildRenderObjectElement { /// Mark a child as needing to stay alive even when it's in a lazy list that /// would otherwise remove it. /// -/// This widget is for use in [SliverWithKeepAliveWidget]s, such as -/// [SliverGrid] or [SliverList]. +/// This widget is for use in a [RenderAbstractViewport]s, such as +/// [Viewport] or [TwoDimensionalViewport]. /// /// This widget is rarely used directly. The [SliverChildBuilderDelegate] and /// [SliverChildListDelegate] delegates, used with [SliverList] and @@ -1322,6 +1322,9 @@ class _SliverOffstageElement extends SingleChildRenderObjectElement { /// each child, causing [KeepAlive] widgets to be automatically added and /// configured in response to [KeepAliveNotification]s. /// +/// The same `addAutomaticKeepAlives` feature is supported by the +/// [TwoDimensionalChildBuilderDelegate] and [TwoDimensionalChildListDelegate]. +/// /// Therefore, to keep a widget alive, it is more common to use those /// notifications than to directly deal with [KeepAlive] widgets. /// @@ -1365,7 +1368,10 @@ class KeepAlive extends ParentDataWidget { bool debugCanApplyOutOfTurn() => keepAlive; @override - Type get debugTypicalAncestorWidgetClass => SliverWithKeepAliveWidget; + Type get debugTypicalAncestorWidgetClass => throw FlutterError('Multiple Types are supported, use debugTypicalAncestorWidgetDescription.'); + + @override + String get debugTypicalAncestorWidgetDescription => 'SliverWithKeepAliveWidget or TwoDimensionalViewport'; @override void debugFillProperties(DiagnosticPropertiesBuilder properties) { diff --git a/packages/flutter/test/widgets/keep_alive_test.dart b/packages/flutter/test/widgets/keep_alive_test.dart index 6bd324f990466..c44843916d72c 100644 --- a/packages/flutter/test/widgets/keep_alive_test.dart +++ b/packages/flutter/test/widgets/keep_alive_test.dart @@ -47,6 +47,14 @@ List generateList(Widget child) { } void main() { + test('KeepAlive debugTypicalAncestorWidgetClass', () { + final KeepAlive keepAlive = KeepAlive(keepAlive: false, child: Container()); + expect( + keepAlive.debugTypicalAncestorWidgetDescription, + 'SliverWithKeepAliveWidget or TwoDimensionalViewport', + ); + }); + testWidgetsWithLeakTracking('KeepAlive with ListView with itemExtent', (WidgetTester tester) async { await tester.pumpWidget( Directionality( From b2f3404ca0d71b853c1d93557aaf74748bc8fab4 Mon Sep 17 00:00:00 2001 From: LongCatIsLooong <31859944+LongCatIsLooong@users.noreply.github.com> Date: Wed, 13 Sep 2023 16:27:13 -0700 Subject: [PATCH 12/16] Remove `Path.combine` call from `CupertionoTextSelectionToolbar` (#134369) Hopefully this fixes https://github.com/flutter/flutter/issues/110076 by removing the `Path.combine` call. Not sure how I can verify in a test that `Path.combine` is not called. --- .../src/cupertino/text_selection_toolbar.dart | 148 +++++++++++------- .../test/cupertino/text_field_test.dart | 11 +- 2 files changed, 101 insertions(+), 58 deletions(-) diff --git a/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart b/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart index e4703a60f1d94..ff1bd07c7a3a0 100644 --- a/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart +++ b/packages/flutter/lib/src/cupertino/text_selection_toolbar.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. import 'dart:collection'; +import 'dart:math' as math show pi; import 'dart:ui' as ui; import 'package:flutter/foundation.dart' show Brightness, clampDouble; @@ -279,87 +280,127 @@ class _RenderCupertinoTextSelectionToolbarShape extends RenderShiftedBox { markNeedsLayout(); } - // The child is tall enough to have the arrow clipped out of it on both sides - // top and bottom. Since _kToolbarHeight includes the height of one arrow, the - // total height that the child is given is that plus one more arrow height. - // The extra height on the opposite side of the arrow will be clipped out. By - // using this approach, the buttons don't need any special padding that - // depends on isAbove. - final BoxConstraints _heightConstraint = BoxConstraints.tightFor( - height: _kToolbarHeight + _kToolbarArrowSize.height, - ); - @override void performLayout() { + final RenderBox? child = this.child; if (child == null) { return; } - final BoxConstraints enforcedConstraint = constraints.loosen(); + // The child is tall enough to have the arrow clipped out of it on both sides + // top and bottom. Since _kToolbarHeight includes the height of one arrow, the + // total height that the child is given is that plus one more arrow height. + // The extra height on the opposite side of the arrow will be clipped out. By + // using this approach, the buttons don't need any special padding that + // depends on isAbove. + final BoxConstraints heightConstraint = BoxConstraints( + minHeight: _kToolbarHeight + _kToolbarArrowSize.height, + maxHeight: _kToolbarHeight + _kToolbarArrowSize.height, + minWidth: _kToolbarArrowSize.width + _kToolbarBorderRadius.x * 2, + ).enforce(constraints.loosen()); - child!.layout(_heightConstraint.enforce(enforcedConstraint), parentUsesSize: true); + child.layout(heightConstraint, parentUsesSize: true); // The height of one arrow will be clipped off of the child, so adjust the // size and position to remove that piece from the layout. - final BoxParentData childParentData = child!.parentData! as BoxParentData; + final BoxParentData childParentData = child.parentData! as BoxParentData; childParentData.offset = Offset( 0.0, _isAbove ? -_kToolbarArrowSize.height : 0.0, ); size = Size( - child!.size.width, - child!.size.height - _kToolbarArrowSize.height, + child.size.width, + child.size.height - _kToolbarArrowSize.height, ); } + // Adds the given `rrect` to the current `path`, starting from the last point + // in `path` and ends after the last corner of the rrect (closest corner to + // `startAngle` in the counterclockwise direction), without closing the path. + // + // The `startAngle` argument must be a multiple of pi / 2, with 0 being the + // positive half of the x-axis, and pi / 2 being the negative half of the + // y-axis. + // + // For instance, if `startAngle` equals pi/2 then this method draws a line + // segment to the bottom-left corner of `rrect` from the last point in `path`, + // and follows the `rrect` path clockwise until the bottom-right corner is + // added, then this method returns the mutated path without closing it. + static Path _addRRectToPath(Path path, RRect rrect, { required double startAngle }) { + const double halfPI = math.pi / 2; + assert(startAngle % halfPI == 0); + final Rect rect = rrect.outerRect; + + final List<(Offset, Radius)> rrectCorners = <(Offset, Radius)>[ + (rect.bottomRight, -rrect.brRadius), + (rect.bottomLeft, Radius.elliptical(rrect.blRadiusX, -rrect.blRadiusY)), + (rect.topLeft, rrect.tlRadius), + (rect.topRight, Radius.elliptical(-rrect.trRadiusX, rrect.trRadiusY)), + ]; + + // Add the 4 corners to the path clockwise. Convert radians to quadrants + // to avoid fp arithmetics. The order is br -> bl -> tl -> tr if the starting + // angle is 0. + final int startQuadrantIndex = startAngle ~/ halfPI; + for (int i = startQuadrantIndex; i < rrectCorners.length + startQuadrantIndex; i += 1) { + final (Offset vertex, Radius rectCenterOffset) = rrectCorners[i % rrectCorners.length]; + final Offset otherVertex = Offset(vertex.dx + 2 * rectCenterOffset.x, vertex.dy + 2 * rectCenterOffset.y); + final Rect rect = Rect.fromPoints(vertex, otherVertex); + path.arcTo(rect, halfPI * i, halfPI, false); + } + return path; + } + // The path is described in the toolbar's coordinate system. - Path _clipPath() { - final BoxParentData childParentData = child!.parentData! as BoxParentData; - final Path rrect = Path() - ..addRRect( - RRect.fromRectAndRadius( - Offset(0.0, _kToolbarArrowSize.height) - & Size( - child!.size.width, - child!.size.height - _kToolbarArrowSize.height * 2, - ), - _kToolbarBorderRadius, - ), - ); + Path _clipPath(RenderBox child) { + final Rect rect = Offset(0.0, _isAbove ? 0 : _kToolbarArrowSize.height) + & Size(size.width, size.height - _kToolbarArrowSize.height); + final RRect rrect = RRect.fromRectAndRadius(rect, _kToolbarBorderRadius).scaleRadii(); + + final Path path = Path(); + // If there isn't enough width for the arrow + radii, ignore the arrow. + // Because of the constraints we gave children in performLayout, this should + // only happen if the parent isn't wide enough which should be very rare, and + // when that happens the arrow won't be too useful anyways. + if (_kToolbarBorderRadius.x * 2 + _kToolbarArrowSize.width > size.width) { + return path..addRRect(rrect); + } final Offset localAnchor = globalToLocal(_anchor); - final double centerX = childParentData.offset.dx + child!.size.width / 2; - final double arrowXOffsetFromCenter = localAnchor.dx - centerX; - final double arrowTipX = child!.size.width / 2 + arrowXOffsetFromCenter; - - final double arrowBaseY = _isAbove - ? child!.size.height - _kToolbarArrowSize.height - : _kToolbarArrowSize.height; - - final double arrowTipY = _isAbove ? child!.size.height : 0; - - final Path arrow = Path() - ..moveTo(arrowTipX, arrowTipY) - ..lineTo(arrowTipX - _kToolbarArrowSize.width / 2, arrowBaseY) - ..lineTo(arrowTipX + _kToolbarArrowSize.width / 2, arrowBaseY) - ..close(); + final double arrowTipX = clampDouble( + localAnchor.dx, + _kToolbarBorderRadius.x + _kToolbarArrowSize.width / 2, + size.width - _kToolbarArrowSize.width / 2 - _kToolbarBorderRadius.x, + ); - return Path.combine(PathOperation.union, rrect, arrow); + // Draw the path clockwise, starting from the beginning side of the arrow. + if (_isAbove) { + path + ..moveTo(arrowTipX + _kToolbarArrowSize.width / 2, rect.bottom) // right side of the arrow triangle + ..lineTo(arrowTipX, rect.bottom + _kToolbarArrowSize.height) // The tip of the arrow + ..lineTo(arrowTipX - _kToolbarArrowSize.width / 2, rect.bottom); // left side of the arrow triangle + } else { + path + ..moveTo(arrowTipX - _kToolbarArrowSize.width / 2, rect.top) // right side of the arrow triangle + ..lineTo(arrowTipX, rect.top) // The tip of the arrow + ..lineTo(arrowTipX + _kToolbarArrowSize.width / 2, rect.top); // left side of the arrow triangle + } + final double startAngle = _isAbove ? math.pi / 2 : -math.pi / 2; + return _addRRectToPath(path, rrect, startAngle: startAngle)..close(); } @override void paint(PaintingContext context, Offset offset) { + final RenderBox? child = this.child; if (child == null) { return; } - - final BoxParentData childParentData = child!.parentData! as BoxParentData; _clipPathLayer.layer = context.pushClipPath( needsCompositing, - offset + childParentData.offset, - Offset.zero & child!.size, - _clipPath(), - (PaintingContext innerContext, Offset innerOffset) => innerContext.paintChild(child!, innerOffset), + offset, + Offset.zero & size, + _clipPath(child), + super.paint, oldLayer: _clipPathLayer.layer, ); } @@ -376,11 +417,12 @@ class _RenderCupertinoTextSelectionToolbarShape extends RenderShiftedBox { @override void debugPaintSize(PaintingContext context, Offset offset) { assert(() { + final RenderBox? child = this.child; if (child == null) { return true; } - _debugPaint ??= Paint() + final ui.Paint debugPaint = _debugPaint ??= Paint() ..shader = ui.Gradient.linear( Offset.zero, const Offset(10.0, 10.0), @@ -391,8 +433,8 @@ class _RenderCupertinoTextSelectionToolbarShape extends RenderShiftedBox { ..strokeWidth = 2.0 ..style = PaintingStyle.stroke; - final BoxParentData childParentData = child!.parentData! as BoxParentData; - context.canvas.drawPath(_clipPath().shift(offset + childParentData.offset), _debugPaint!); + final BoxParentData childParentData = child.parentData! as BoxParentData; + context.canvas.drawPath(_clipPath(child).shift(offset + childParentData.offset), debugPaint); return true; }()); } diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index bd5721aba3c64..df3001ce6ce60 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -6835,8 +6835,9 @@ void main() { bottomLeftSelectionPosition.translate(0, 8 + 0.1), ], includes: [ - // Expected center of the arrow. - Offset(26.0, bottomLeftSelectionPosition.dy + 8 + 0.1), + // Expected center of the arrow. The arrow should stay clear of + // the edges of the selection toolbar. + Offset(26.0, bottomLeftSelectionPosition.dy + 7.0 + 8.0 + 0.1), ], ), ), @@ -6846,7 +6847,7 @@ void main() { find.byType(CupertinoTextSelectionToolbar), paints..clipPath( pathMatcher: PathBoundsMatcher( - topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8, epsilon: 0.01), + topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 7 + 8, epsilon: 0.01), leftMatcher: moreOrLessEquals(8), rightMatcher: lessThanOrEqualTo(400 - 8), bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8 + 45, epsilon: 0.01), @@ -6897,7 +6898,7 @@ void main() { ], includes: [ // Expected center of the arrow. - Offset(400 - 26.0, bottomLeftSelectionPosition.dy + 8 + 0.1), + Offset(400 - 26.0, bottomLeftSelectionPosition.dy + 7 + 8 + 0.1), ], ), ), @@ -6907,7 +6908,7 @@ void main() { find.byType(CupertinoTextSelectionToolbar), paints..clipPath( pathMatcher: PathBoundsMatcher( - topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8, epsilon: 0.01), + topMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 7 + 8, epsilon: 0.01), rightMatcher: moreOrLessEquals(400.0 - 8), bottomMatcher: moreOrLessEquals(bottomLeftSelectionPosition.dy + 8 + 45, epsilon: 0.01), leftMatcher: greaterThanOrEqualTo(8), From ee9aef0130fd9ec2cd958f92508d93679f7ce46b Mon Sep 17 00:00:00 2001 From: Polina Cherkasova Date: Wed, 13 Sep 2023 19:48:22 -0700 Subject: [PATCH 13/16] _DayPicker should build days using separate stetefull widget _Day. (#134607) Fixes https://github.com/flutter/flutter/issues/134323 --- .../src/material/calendar_date_picker.dart | 207 +++++++++++------- 1 file changed, 125 insertions(+), 82 deletions(-) diff --git a/packages/flutter/lib/src/material/calendar_date_picker.dart b/packages/flutter/lib/src/material/calendar_date_picker.dart index 303bb43be1773..9bc419eac50dd 100644 --- a/packages/flutter/lib/src/material/calendar_date_picker.dart +++ b/packages/flutter/lib/src/material/calendar_date_picker.dart @@ -868,10 +868,6 @@ class _DayPickerState extends State<_DayPicker> { /// List of [FocusNode]s, one for each day of the month. late List _dayFocusNodes; - // TODO(polina-c): a cleaner solution is to create separate statefull widget for a day. - // https://github.com/flutter/flutter/issues/134323 - final Map _statesControllers = {}; - @override void initState() { super.initState(); @@ -897,9 +893,6 @@ class _DayPickerState extends State<_DayPicker> { for (final FocusNode node in _dayFocusNodes) { node.dispose(); } - for (final MaterialStatesController controller in _statesControllers.values) { - controller.dispose(); - } super.dispose(); } @@ -937,7 +930,6 @@ class _DayPickerState extends State<_DayPicker> { final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); final DatePickerThemeData defaults = DatePickerTheme.defaults(context); final TextStyle? weekdayStyle = datePickerTheme.weekdayStyle ?? defaults.weekdayStyle; - final TextStyle? dayStyle = datePickerTheme.dayStyle ?? defaults.dayStyle; final int year = widget.displayedMonth.year; final int month = widget.displayedMonth.month; @@ -945,18 +937,6 @@ class _DayPickerState extends State<_DayPicker> { final int daysInMonth = DateUtils.getDaysInMonth(year, month); final int dayOffset = DateUtils.firstDayOffset(year, month, localizations); - T? effectiveValue(T? Function(DatePickerThemeData? theme) getProperty) { - return getProperty(datePickerTheme) ?? getProperty(defaults); - } - - T? resolve(MaterialStateProperty? Function(DatePickerThemeData? theme) getProperty, Set states) { - return effectiveValue( - (DatePickerThemeData? theme) { - return getProperty(theme)?.resolve(states); - }, - ); - } - final List dayItems = _dayHeaders(weekdayStyle, localizations); // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on // a leap year. @@ -973,71 +953,18 @@ class _DayPickerState extends State<_DayPicker> { (widget.selectableDayPredicate != null && !widget.selectableDayPredicate!(dayToBuild)); final bool isSelectedDay = DateUtils.isSameDay(widget.selectedDate, dayToBuild); final bool isToday = DateUtils.isSameDay(widget.currentDate, dayToBuild); - final String semanticLabelSuffix = isToday ? ', ${localizations.currentDateLabel}' : ''; - - final Set states = { - if (isDisabled) MaterialState.disabled, - if (isSelectedDay) MaterialState.selected, - }; - - final MaterialStatesController statesController = _statesControllers.putIfAbsent(day, () => MaterialStatesController()); - statesController.value = states; - final Color? dayForegroundColor = resolve((DatePickerThemeData? theme) => isToday ? theme?.todayForegroundColor : theme?.dayForegroundColor, states); - final Color? dayBackgroundColor = resolve((DatePickerThemeData? theme) => isToday ? theme?.todayBackgroundColor : theme?.dayBackgroundColor, states); - final MaterialStateProperty dayOverlayColor = MaterialStateProperty.resolveWith( - (Set states) => effectiveValue((DatePickerThemeData? theme) => theme?.dayOverlayColor?.resolve(states)), - ); - final BoxDecoration decoration = isToday - ? BoxDecoration( - color: dayBackgroundColor, - border: Border.fromBorderSide( - (datePickerTheme.todayBorder ?? defaults.todayBorder!) - .copyWith(color: dayForegroundColor) - ), - shape: BoxShape.circle, - ) - : BoxDecoration( - color: dayBackgroundColor, - shape: BoxShape.circle, - ); - - Widget dayWidget = Container( - decoration: decoration, - child: Center( - child: Text(localizations.formatDecimal(day), style: dayStyle?.apply(color: dayForegroundColor)), + dayItems.add( + _Day( + dayToBuild, + key: ValueKey(dayToBuild), + isDisabled: isDisabled, + isSelectedDay: isSelectedDay, + isToday: isToday, + onChanged: widget.onChanged, + focusNode: _dayFocusNodes[day - 1], ), ); - - if (isDisabled) { - dayWidget = ExcludeSemantics( - child: dayWidget, - ); - } else { - dayWidget = InkResponse( - focusNode: _dayFocusNodes[day - 1], - onTap: () => widget.onChanged(dayToBuild), - radius: _dayPickerRowHeight / 2 + 4, - statesController: statesController, - overlayColor: dayOverlayColor, - child: Semantics( - // We want the day of month to be spoken first irrespective of the - // locale-specific preferences or TextDirection. This is because - // an accessibility user is more likely to be interested in the - // day of month before the rest of the date, as they are looking - // for the day of month. To do that we prepend day of month to the - // formatted full date. - label: '${localizations.formatDecimal(day)}, ${localizations.formatFullDate(dayToBuild)}$semanticLabelSuffix', - // Set button to true to make the date selectable. - button: true, - selected: isSelectedDay, - excludeSemantics: true, - child: dayWidget, - ), - ); - } - - dayItems.add(dayWidget); } } @@ -1057,6 +984,122 @@ class _DayPickerState extends State<_DayPicker> { } } +class _Day extends StatefulWidget { + const _Day( + this.day, { + super.key, + required this.isDisabled, + required this.isSelectedDay, + required this.isToday, + required this.onChanged, + required this.focusNode, + }); + + final DateTime day; + final bool isDisabled; + final bool isSelectedDay; + final bool isToday; + final ValueChanged onChanged; + final FocusNode? focusNode; + + @override + State<_Day> createState() => _DayState(); +} + +class _DayState extends State<_Day> { + final MaterialStatesController _statesController = MaterialStatesController(); + + @override + Widget build(BuildContext context) { + final DatePickerThemeData defaults = DatePickerTheme.defaults(context); + final DatePickerThemeData datePickerTheme = DatePickerTheme.of(context); + final TextStyle? dayStyle = datePickerTheme.dayStyle ?? defaults.dayStyle; + T? effectiveValue(T? Function(DatePickerThemeData? theme) getProperty) { + return getProperty(datePickerTheme) ?? getProperty(defaults); + } + + T? resolve(MaterialStateProperty? Function(DatePickerThemeData? theme) getProperty, Set states) { + return effectiveValue( + (DatePickerThemeData? theme) { + return getProperty(theme)?.resolve(states); + }, + ); + } + + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final String semanticLabelSuffix = widget.isToday ? ', ${localizations.currentDateLabel}' : ''; + + final Set states = { + if (widget.isDisabled) MaterialState.disabled, + if (widget.isSelectedDay) MaterialState.selected, + }; + + _statesController.value = states; + + final Color? dayForegroundColor = resolve((DatePickerThemeData? theme) => widget.isToday ? theme?.todayForegroundColor : theme?.dayForegroundColor, states); + final Color? dayBackgroundColor = resolve((DatePickerThemeData? theme) => widget.isToday ? theme?.todayBackgroundColor : theme?.dayBackgroundColor, states); + final MaterialStateProperty dayOverlayColor = MaterialStateProperty.resolveWith( + (Set states) => effectiveValue((DatePickerThemeData? theme) => theme?.dayOverlayColor?.resolve(states)), + ); + final BoxDecoration decoration = widget.isToday + ? BoxDecoration( + color: dayBackgroundColor, + border: Border.fromBorderSide( + (datePickerTheme.todayBorder ?? defaults.todayBorder!) + .copyWith(color: dayForegroundColor) + ), + shape: BoxShape.circle, + ) + : BoxDecoration( + color: dayBackgroundColor, + shape: BoxShape.circle, + ); + + Widget dayWidget = Container( + decoration: decoration, + child: Center( + child: Text(localizations.formatDecimal(widget.day.day), style: dayStyle?.apply(color: dayForegroundColor)), + ), + ); + + if (widget.isDisabled) { + dayWidget = ExcludeSemantics( + child: dayWidget, + ); + } else { + dayWidget = InkResponse( + focusNode: widget.focusNode, + onTap: () => widget.onChanged(widget.day), + radius: _dayPickerRowHeight / 2 + 4, + statesController: _statesController, + overlayColor: dayOverlayColor, + child: Semantics( + // We want the day of month to be spoken first irrespective of the + // locale-specific preferences or TextDirection. This is because + // an accessibility user is more likely to be interested in the + // day of month before the rest of the date, as they are looking + // for the day of month. To do that we prepend day of month to the + // formatted full date. + label: '${localizations.formatDecimal(widget.day.day)}, ${localizations.formatFullDate(widget.day)}$semanticLabelSuffix', + // Set button to true to make the date selectable. + button: true, + selected: widget.isSelectedDay, + excludeSemantics: true, + child: dayWidget, + ), + ); + } + + return dayWidget; + } + + @override + void dispose() { + _statesController.dispose(); + super.dispose(); + } +} + class _DayPickerGridDelegate extends SliverGridDelegate { const _DayPickerGridDelegate(); From 4db47db177492eade1b0cf6199aa05a6e58b3f3d Mon Sep 17 00:00:00 2001 From: Justin McCandless Date: Wed, 13 Sep 2023 20:39:58 -0700 Subject: [PATCH 14/16] LinkedText (Linkify) (#125927) New LinkedText widget and TextLinker class for easily adding hyperlinks to text. --- .../painting/text_linker/text_linker.0.dart | 213 +++++++++ .../painting/text_linker/text_linker.1.dart | 235 ++++++++++ .../widgets/linked_text/linked_text.0.dart | 75 ++++ .../widgets/linked_text/linked_text.1.dart | 85 ++++ .../widgets/linked_text/linked_text.2.dart | 92 ++++ .../widgets/linked_text/linked_text.3.dart | 184 ++++++++ .../text_linker/text_linker.0_test.dart | 36 ++ .../text_linker/text_linker.1_test.dart | 36 ++ .../linked_text/linked_text.0_test.dart | 27 ++ .../linked_text/linked_text.1_test.dart | 27 ++ .../linked_text/linked_text.2_test.dart | 27 ++ .../linked_text/linked_text.3_test.dart | 36 ++ packages/flutter/lib/painting.dart | 1 + .../flutter/lib/src/painting/text_linker.dart | 422 ++++++++++++++++++ .../flutter/lib/src/widgets/linked_text.dart | 334 ++++++++++++++ packages/flutter/lib/widgets.dart | 1 + .../test/painting/text_linker_test.dart | 322 +++++++++++++ .../test/widgets/linked_text_test.dart | 367 +++++++++++++++ 18 files changed, 2520 insertions(+) create mode 100644 examples/api/lib/painting/text_linker/text_linker.0.dart create mode 100644 examples/api/lib/painting/text_linker/text_linker.1.dart create mode 100644 examples/api/lib/widgets/linked_text/linked_text.0.dart create mode 100644 examples/api/lib/widgets/linked_text/linked_text.1.dart create mode 100644 examples/api/lib/widgets/linked_text/linked_text.2.dart create mode 100644 examples/api/lib/widgets/linked_text/linked_text.3.dart create mode 100644 examples/api/test/painting/text_linker/text_linker.0_test.dart create mode 100644 examples/api/test/painting/text_linker/text_linker.1_test.dart create mode 100644 examples/api/test/widgets/linked_text/linked_text.0_test.dart create mode 100644 examples/api/test/widgets/linked_text/linked_text.1_test.dart create mode 100644 examples/api/test/widgets/linked_text/linked_text.2_test.dart create mode 100644 examples/api/test/widgets/linked_text/linked_text.3_test.dart create mode 100644 packages/flutter/lib/src/painting/text_linker.dart create mode 100644 packages/flutter/lib/src/widgets/linked_text.dart create mode 100644 packages/flutter/test/painting/text_linker_test.dart create mode 100644 packages/flutter/test/widgets/linked_text_test.dart diff --git a/examples/api/lib/painting/text_linker/text_linker.0.dart b/examples/api/lib/painting/text_linker/text_linker.0.dart new file mode 100644 index 0000000000000..15bcd93b537a2 --- /dev/null +++ b/examples/api/lib/painting/text_linker/text_linker.0.dart @@ -0,0 +1,213 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +// This example demonstrates highlighting both URLs and Twitter handles with +// different actions and different styles. + +void main() { + runApp(const TextLinkerApp()); +} + +class TextLinkerApp extends StatelessWidget { + const TextLinkerApp({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(title: 'Flutter Link Twitter Handle Demo'), + ); + } +} + +class MyHomePage extends StatelessWidget { + const MyHomePage({ + super.key, + required this.title + }); + + final String title; + static const String _text = '@FlutterDev is our Twitter account, or find us at www.flutter.dev'; + + void _handleTapTwitterHandle(BuildContext context, String linkString) { + final String handleWithoutAt = linkString.substring(1); + final String twitterUriString = 'https://www.twitter.com/$handleWithoutAt'; + final Uri? uri = Uri.tryParse(twitterUriString); + if (uri == null) { + throw Exception('Failed to parse $twitterUriString.'); + } + _showDialog(context, uri); + } + + void _handleTapUrl(BuildContext context, String urlText) { + final Uri? uri = Uri.tryParse(urlText); + if (uri == null) { + throw Exception('Failed to parse $urlText.'); + } + _showDialog(context, uri); + } + + void _showDialog(BuildContext context, Uri uri) { + // A package like url_launcher would be useful for actually opening the URL + // here instead of just showing a dialog. + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Builder( + builder: (BuildContext context) { + return SelectionArea( + child: _TwitterAndUrlLinkedText( + text: _text, + onTapUrl: (String urlString) => _handleTapUrl(context, urlString), + onTapTwitterHandle: (String handleString) => _handleTapTwitterHandle(context, handleString), + ), + ); + }, + ), + ), + ); + } +} + +class _TwitterAndUrlLinkedText extends StatefulWidget { + const _TwitterAndUrlLinkedText({ + required this.text, + required this.onTapUrl, + required this.onTapTwitterHandle, + }); + + final String text; + final ValueChanged onTapUrl; + final ValueChanged onTapTwitterHandle; + + @override + State<_TwitterAndUrlLinkedText> createState() => _TwitterAndUrlLinkedTextState(); +} + +class _TwitterAndUrlLinkedTextState extends State<_TwitterAndUrlLinkedText> { + final List _recognizers = []; + late Iterable _linkedSpans; + late final List _textLinkers; + + final RegExp _twitterHandleRegExp = RegExp(r'@[a-zA-Z0-9]{4,15}'); + + void _disposeRecognizers() { + for (final GestureRecognizer recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + void _linkSpans() { + _disposeRecognizers(); + final Iterable linkedSpans = TextLinker.linkSpans( + [TextSpan(text: widget.text)], + _textLinkers, + ); + _linkedSpans = linkedSpans; + } + + @override + void initState() { + super.initState(); + + _textLinkers = [ + TextLinker( + regExp: LinkedText.defaultUriRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () => widget.onTapUrl(linkString); + _recognizers.add(recognizer); + return _MyInlineLinkSpan( + text: displayString, + color: const Color(0xff0000ee), + recognizer: recognizer, + ); + }, + ), + TextLinker( + regExp: _twitterHandleRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () => widget.onTapTwitterHandle(linkString); + _recognizers.add(recognizer); + return _MyInlineLinkSpan( + text: displayString, + color: const Color(0xff00aaaa), + recognizer: recognizer, + ); + }, + ), + ]; + + _linkSpans(); + } + + @override + void didUpdateWidget(_TwitterAndUrlLinkedText oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.text != oldWidget.text + || widget.onTapUrl != oldWidget.onTapUrl + || widget.onTapTwitterHandle != oldWidget.onTapTwitterHandle) { + _linkSpans(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_linkedSpans.isEmpty) { + return const SizedBox.shrink(); + } + + return Text.rich( + TextSpan( + style: DefaultTextStyle.of(context).style, + children: _linkedSpans.toList(), + ), + ); + } +} + +class _MyInlineLinkSpan extends TextSpan { + _MyInlineLinkSpan({ + required String text, + required Color color, + required super.recognizer, + }) : super( + style: TextStyle( + color: color, + decorationColor: color, + decoration: TextDecoration.underline, + ), + mouseCursor: SystemMouseCursors.click, + text: text, + ); +} diff --git a/examples/api/lib/painting/text_linker/text_linker.1.dart b/examples/api/lib/painting/text_linker/text_linker.1.dart new file mode 100644 index 0000000000000..203a926cdd62f --- /dev/null +++ b/examples/api/lib/painting/text_linker/text_linker.1.dart @@ -0,0 +1,235 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +// This example demonstrates creating links in a TextSpan tree instead of a flat +// String. + +void main() { + runApp(const TextLinkerApp()); +} + +class TextLinkerApp extends StatelessWidget { + const TextLinkerApp({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(title: 'Flutter TextLinker Span Demo'), + ); + } +} + +class MyHomePage extends StatelessWidget { + const MyHomePage({ + super.key, + required this.title + }); + + final String title; + + void _handleTapTwitterHandle(BuildContext context, String linkString) { + final String handleWithoutAt = linkString.substring(1); + final String twitterUriString = 'https://www.twitter.com/$handleWithoutAt'; + final Uri? uri = Uri.tryParse(twitterUriString); + if (uri == null) { + throw Exception('Failed to parse $twitterUriString.'); + } + _showDialog(context, uri); + } + + void _handleTapUrl(BuildContext context, String urlText) { + final Uri? uri = Uri.tryParse(urlText); + if (uri == null) { + throw Exception('Failed to parse $urlText.'); + } + _showDialog(context, uri); + } + + void _showDialog(BuildContext context, Uri uri) { + // A package like url_launcher would be useful for actually opening the URL + // here instead of just showing a dialog. + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Builder( + builder: (BuildContext context) { + return SelectionArea( + child: _TwitterAndUrlLinkedText( + spans: [ + TextSpan( + text: '@FlutterDev is our Twitter, or find us at www.', + style: DefaultTextStyle.of(context).style, + children: const [ + TextSpan( + style: TextStyle( + fontWeight: FontWeight.w800, + ), + text: 'flutter', + ), + ], + ), + TextSpan( + text: '.dev', + style: DefaultTextStyle.of(context).style, + ), + ], + onTapUrl: (String urlString) => _handleTapUrl(context, urlString), + onTapTwitterHandle: (String handleString) => _handleTapTwitterHandle(context, handleString), + ), + ); + }, + ), + ), + ); + } +} + +class _TwitterAndUrlLinkedText extends StatefulWidget { + const _TwitterAndUrlLinkedText({ + required this.spans, + required this.onTapUrl, + required this.onTapTwitterHandle, + }); + + final List spans; + final ValueChanged onTapUrl; + final ValueChanged onTapTwitterHandle; + + @override + State<_TwitterAndUrlLinkedText> createState() => _TwitterAndUrlLinkedTextState(); +} + +class _TwitterAndUrlLinkedTextState extends State<_TwitterAndUrlLinkedText> { + final List _recognizers = []; + late Iterable _linkedSpans; + late final List _textLinkers; + + final RegExp _twitterHandleRegExp = RegExp(r'@[a-zA-Z0-9]{4,15}'); + + void _disposeRecognizers() { + for (final GestureRecognizer recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + void _linkSpans() { + _disposeRecognizers(); + final Iterable linkedSpans = TextLinker.linkSpans( + widget.spans, + _textLinkers, + ); + _linkedSpans = linkedSpans; + } + + @override + void initState() { + super.initState(); + + _textLinkers = [ + TextLinker( + regExp: LinkedText.defaultUriRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + // The linkString always contains the full matched text, so that's + // what should be linked to. + ..onTap = () => widget.onTapUrl(linkString); + _recognizers.add(recognizer); + return _MyInlineLinkSpan( + // The displayString contains only the portion of the matched text + // in a given TextSpan. For example, the bold "flutter" text in + // the overall "www.flutter.dev" URL is in its own TextSpan with its + // bold styling. linkBuilder is called separately for each part. + text: displayString, + color: const Color(0xff0000ee), + recognizer: recognizer, + ); + }, + ), + TextLinker( + regExp: _twitterHandleRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () => widget.onTapTwitterHandle(linkString); + _recognizers.add(recognizer); + return _MyInlineLinkSpan( + text: displayString, + color: const Color(0xff00aaaa), + recognizer: recognizer, + ); + }, + ), + ]; + + _linkSpans(); + } + + @override + void didUpdateWidget(_TwitterAndUrlLinkedText oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.spans != oldWidget.spans + || widget.onTapUrl != oldWidget.onTapUrl + || widget.onTapTwitterHandle != oldWidget.onTapTwitterHandle) { + _linkSpans(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (_linkedSpans.isEmpty) { + return const SizedBox.shrink(); + } + + return Text.rich( + TextSpan( + style: DefaultTextStyle.of(context).style, + children: _linkedSpans.toList(), + ), + ); + } +} + +class _MyInlineLinkSpan extends TextSpan { + _MyInlineLinkSpan({ + required String text, + required Color color, + required super.recognizer, + }) : super( + style: TextStyle( + color: color, + decorationColor: color, + decoration: TextDecoration.underline, + ), + mouseCursor: SystemMouseCursors.click, + text: text, + ); +} diff --git a/examples/api/lib/widgets/linked_text/linked_text.0.dart b/examples/api/lib/widgets/linked_text/linked_text.0.dart new file mode 100644 index 0000000000000..e9c3b5cdaff50 --- /dev/null +++ b/examples/api/lib/widgets/linked_text/linked_text.0.dart @@ -0,0 +1,75 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +// This example demonstrates using LinkedText to make URLs open on tap. + +void main() { + runApp(const LinkedTextApp()); +} + +class LinkedTextApp extends StatelessWidget { + const LinkedTextApp({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(title: 'Flutter Link Demo'), + ); + } +} + +class MyHomePage extends StatelessWidget { + const MyHomePage({ + super.key, + required this.title, + }); + + final String title; + static const String _text = 'Check out https://www.flutter.dev, or maybe just flutter.dev or www.flutter.dev.'; + + void _handleTapUri(BuildContext context, Uri uri) { + // A package like url_launcher would be useful for actually opening the URL + // here instead of just showing a dialog. + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Builder( + builder: (BuildContext context) { + return SelectionArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LinkedText( + text: _text, + onTapUri: (Uri uri) => _handleTapUri(context, uri), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/examples/api/lib/widgets/linked_text/linked_text.1.dart b/examples/api/lib/widgets/linked_text/linked_text.1.dart new file mode 100644 index 0000000000000..48c4df841ff1e --- /dev/null +++ b/examples/api/lib/widgets/linked_text/linked_text.1.dart @@ -0,0 +1,85 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +// This example demonstrates highlighting and linking Twitter handles. + +void main() { + runApp(const LinkedTextApp()); +} + +class LinkedTextApp extends StatelessWidget { + const LinkedTextApp({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: MyHomePage(title: 'Flutter Link Twitter Handle Demo'), + ); + } +} + +class MyHomePage extends StatelessWidget { + MyHomePage({ + super.key, + required this.title + }); + + final String title; + static const String _text = 'Please check out @FlutterDev on Twitter for the latest.'; + + void _handleTapTwitterHandle(BuildContext context, String linkText) { + final String handleWithoutAt = linkText.substring(1); + final String twitterUriString = 'https://www.twitter.com/$handleWithoutAt'; + final Uri? uri = Uri.tryParse(twitterUriString); + if (uri == null) { + throw Exception('Failed to parse $twitterUriString.'); + } + + // A package like url_launcher would be useful for actually opening the URL + // here instead of just showing a dialog. + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')), + ), + ); + } + + final RegExp _twitterHandleRegExp = RegExp(r'@[a-zA-Z0-9]{4,15}'); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Builder( + builder: (BuildContext context) { + return SelectionArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LinkedText.regExp( + text: _text, + regExp: _twitterHandleRegExp, + onTap: (String twitterHandleString) => _handleTapTwitterHandle(context, twitterHandleString), + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/examples/api/lib/widgets/linked_text/linked_text.2.dart b/examples/api/lib/widgets/linked_text/linked_text.2.dart new file mode 100644 index 0000000000000..a19a07e4d85e6 --- /dev/null +++ b/examples/api/lib/widgets/linked_text/linked_text.2.dart @@ -0,0 +1,92 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +// This example demonstrates highlighting URLs in a TextSpan tree instead of a +// flat String. + +void main() { + runApp(const LinkedTextApp()); +} + +class LinkedTextApp extends StatelessWidget { + const LinkedTextApp({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(title: 'Flutter LinkedText.spans Demo'), + ); + } +} + +class MyHomePage extends StatelessWidget { + const MyHomePage({ + super.key, + required this.title, + }); + + final String title; + + void _onTapUri (BuildContext context, Uri uri) { + // A package like url_launcher would be useful for actually opening the URL + // here instead of just showing a dialog. + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Builder( + builder: (BuildContext context) { + return SelectionArea( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + LinkedText( + onTapUri: (Uri uri) => _onTapUri(context, uri), + spans: [ + TextSpan( + text: 'Check out https://www.', + style: DefaultTextStyle.of(context).style, + children: const [ + TextSpan( + style: TextStyle( + fontWeight: FontWeight.w800, + ), + text: 'flutter', + ), + ], + ), + TextSpan( + text: '.dev!', + style: DefaultTextStyle.of(context).style, + ), + ], + ), + ], + ), + ); + }, + ), + ), + ); + } +} diff --git a/examples/api/lib/widgets/linked_text/linked_text.3.dart b/examples/api/lib/widgets/linked_text/linked_text.3.dart new file mode 100644 index 0000000000000..72db9ec91358e --- /dev/null +++ b/examples/api/lib/widgets/linked_text/linked_text.3.dart @@ -0,0 +1,184 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +// This example demonstrates highlighting both URLs and Twitter handles with +// different actions and different styles. + +void main() { + runApp(const LinkedTextApp()); +} + +class LinkedTextApp extends StatelessWidget { + const LinkedTextApp({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'Flutter Demo', + theme: ThemeData( + primarySwatch: Colors.blue, + ), + home: const MyHomePage(title: 'Flutter Link Twitter Handle Demo'), + ); + } +} + +class MyHomePage extends StatelessWidget { + const MyHomePage({ + super.key, + required this.title + }); + + final String title; + static const String _text = '@FlutterDev is our Twitter account, or find us at www.flutter.dev'; + + void _handleTapTwitterHandle(BuildContext context, String linkText) { + final String handleWithoutAt = linkText.substring(1); + final String twitterUriString = 'https://www.twitter.com/$handleWithoutAt'; + final Uri? uri = Uri.tryParse(twitterUriString); + if (uri == null) { + throw Exception('Failed to parse $twitterUriString.'); + } + _showDialog(context, uri); + } + + void _handleTapUrl(BuildContext context, String urlText) { + final Uri? uri = Uri.tryParse(urlText); + if (uri == null) { + throw Exception('Failed to parse $urlText.'); + } + _showDialog(context, uri); + } + + void _showDialog(BuildContext context, Uri uri) { + // A package like url_launcher would be useful for actually opening the URL + // here instead of just showing a dialog. + Navigator.of(context).push( + DialogRoute( + context: context, + builder: (BuildContext context) => AlertDialog(title: Text('You tapped: $uri')), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(title), + ), + body: Center( + child: Builder( + builder: (BuildContext context) { + return SelectionArea( + child: _TwitterAndUrlLinkedText( + text: _text, + onTapUrl: (String urlString) => _handleTapUrl(context, urlString), + onTapTwitterHandle: (String handleString) => _handleTapTwitterHandle(context, handleString), + ), + ); + }, + ), + ), + ); + } +} + +class _TwitterAndUrlLinkedText extends StatefulWidget { + const _TwitterAndUrlLinkedText({ + required this.text, + required this.onTapUrl, + required this.onTapTwitterHandle, + }); + + final String text; + final ValueChanged onTapUrl; + final ValueChanged onTapTwitterHandle; + + @override + State<_TwitterAndUrlLinkedText> createState() => _TwitterAndUrlLinkedTextState(); +} + +class _TwitterAndUrlLinkedTextState extends State<_TwitterAndUrlLinkedText> { + final List _recognizers = []; + late final List _textLinkers; + + final RegExp _twitterHandleRegExp = RegExp(r'@[a-zA-Z0-9]{4,15}'); + + void _disposeRecognizers() { + for (final GestureRecognizer recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + @override + void initState() { + super.initState(); + + _textLinkers = [ + TextLinker( + regExp: LinkedText.defaultUriRegExp, + linkBuilder: (String displayText, String linkText) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () => widget.onTapUrl(linkText); + _recognizers.add(recognizer); + return _MyInlineLinkSpan( + text: displayText, + color: const Color(0xff0000ee), + recognizer: recognizer, + ); + }, + ), + TextLinker( + regExp: _twitterHandleRegExp, + linkBuilder: (String displayText, String linkText) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () => widget.onTapTwitterHandle(linkText); + _recognizers.add(recognizer); + return _MyInlineLinkSpan( + text: displayText, + color: const Color(0xff00aaaa), + recognizer: recognizer, + ); + }, + ), + ]; + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LinkedText.textLinkers( + text: widget.text, + textLinkers: _textLinkers, + ); + } +} + +class _MyInlineLinkSpan extends TextSpan { + _MyInlineLinkSpan({ + required String text, + required Color color, + required super.recognizer, + }) : super( + style: TextStyle( + color: color, + decorationColor: color, + decoration: TextDecoration.underline, + ), + mouseCursor: SystemMouseCursors.click, + text: text, + ); +} diff --git a/examples/api/test/painting/text_linker/text_linker.0_test.dart b/examples/api/test/painting/text_linker/text_linker.0_test.dart new file mode 100644 index 0000000000000..a9ef1fe947a19 --- /dev/null +++ b/examples/api/test/painting/text_linker/text_linker.0_test.dart @@ -0,0 +1,36 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/painting/text_linker/text_linker.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('can tap different link types with different results', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TextLinkerApp(), + ); + + final Finder textFinder = find.descendant( + of: find.byType(SelectionArea), + matching: find.byType(Text), + ); + expect(textFinder, findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getTopLeft(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: https://www.twitter.com/FlutterDev'), findsOneWidget); + + await tester.tapAt(tester.getTopLeft(find.byType(Scaffold))); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getCenter(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: www.flutter.dev'), findsOneWidget); + }); +} diff --git a/examples/api/test/painting/text_linker/text_linker.1_test.dart b/examples/api/test/painting/text_linker/text_linker.1_test.dart new file mode 100644 index 0000000000000..3d32fe9fea155 --- /dev/null +++ b/examples/api/test/painting/text_linker/text_linker.1_test.dart @@ -0,0 +1,36 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/painting/text_linker/text_linker.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('can tap different link types with different results', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TextLinkerApp(), + ); + + final Finder textFinder = find.descendant( + of: find.byType(SelectionArea), + matching: find.byType(Text), + ); + expect(textFinder, findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getTopLeft(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: https://www.twitter.com/FlutterDev'), findsOneWidget); + + await tester.tapAt(tester.getTopLeft(find.byType(Scaffold))); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getCenter(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: www.flutter.dev'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/linked_text/linked_text.0_test.dart b/examples/api/test/widgets/linked_text/linked_text.0_test.dart new file mode 100644 index 0000000000000..d397d94f790d9 --- /dev/null +++ b/examples/api/test/widgets/linked_text/linked_text.0_test.dart @@ -0,0 +1,27 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/linked_text/linked_text.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('tapping a link shows a dialog with the tapped uri', (WidgetTester tester) async { + await tester.pumpWidget( + const example.LinkedTextApp(), + ); + + final Finder textFinder = find.descendant( + of: find.byType(LinkedText), + matching: find.byType(RichText), + ); + expect(textFinder, findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getCenter(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: https://www.flutter.dev'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/linked_text/linked_text.1_test.dart b/examples/api/test/widgets/linked_text/linked_text.1_test.dart new file mode 100644 index 0000000000000..7ba5b7ce945cd --- /dev/null +++ b/examples/api/test/widgets/linked_text/linked_text.1_test.dart @@ -0,0 +1,27 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/linked_text/linked_text.1.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('tapping a Twitter handle shows a dialog with the uri of the user', (WidgetTester tester) async { + await tester.pumpWidget( + const example.LinkedTextApp(), + ); + + final Finder textFinder = find.descendant( + of: find.byType(LinkedText), + matching: find.byType(RichText), + ); + expect(textFinder, findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getCenter(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: https://www.twitter.com/FlutterDev'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/linked_text/linked_text.2_test.dart b/examples/api/test/widgets/linked_text/linked_text.2_test.dart new file mode 100644 index 0000000000000..1d5f97f90a4b7 --- /dev/null +++ b/examples/api/test/widgets/linked_text/linked_text.2_test.dart @@ -0,0 +1,27 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/linked_text/linked_text.2.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('can tap links generated from TextSpans', (WidgetTester tester) async { + await tester.pumpWidget( + const example.LinkedTextApp(), + ); + + final Finder textFinder = find.descendant( + of: find.byType(LinkedText), + matching: find.byType(RichText), + ); + expect(textFinder, findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getCenter(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: https://www.flutter.dev'), findsOneWidget); + }); +} diff --git a/examples/api/test/widgets/linked_text/linked_text.3_test.dart b/examples/api/test/widgets/linked_text/linked_text.3_test.dart new file mode 100644 index 0000000000000..4911cb8334110 --- /dev/null +++ b/examples/api/test/widgets/linked_text/linked_text.3_test.dart @@ -0,0 +1,36 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/widgets/linked_text/linked_text.3.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('can tap different link types with different results', (WidgetTester tester) async { + await tester.pumpWidget( + const example.LinkedTextApp(), + ); + + final Finder textFinder = find.descendant( + of: find.byType(SelectionArea), + matching: find.byType(Text), + ); + expect(textFinder, findsOneWidget); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getTopLeft(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: https://www.twitter.com/FlutterDev'), findsOneWidget); + + await tester.tapAt(tester.getTopLeft(find.byType(Scaffold))); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsNothing); + + await tester.tapAt(tester.getCenter(textFinder)); + await tester.pumpAndSettle(); + expect(find.byType(AlertDialog), findsOneWidget); + expect(find.text('You tapped: www.flutter.dev'), findsOneWidget); + }); +} diff --git a/packages/flutter/lib/painting.dart b/packages/flutter/lib/painting.dart index 2fa89c23cf8bc..952763378515e 100644 --- a/packages/flutter/lib/painting.dart +++ b/packages/flutter/lib/painting.dart @@ -59,6 +59,7 @@ export 'src/painting/shape_decoration.dart'; export 'src/painting/stadium_border.dart'; export 'src/painting/star_border.dart'; export 'src/painting/strut_style.dart'; +export 'src/painting/text_linker.dart'; export 'src/painting/text_painter.dart'; export 'src/painting/text_scaler.dart'; export 'src/painting/text_span.dart'; diff --git a/packages/flutter/lib/src/painting/text_linker.dart b/packages/flutter/lib/src/painting/text_linker.dart new file mode 100644 index 0000000000000..4684be30fb083 --- /dev/null +++ b/packages/flutter/lib/src/painting/text_linker.dart @@ -0,0 +1,422 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math' as math; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'inline_span.dart'; +import 'text_span.dart'; + +/// Signature for a function that builds an [InlineSpan] link. +/// +/// The link displays [displayString] and links to [linkString] when tapped. +/// These are distinct because sometimes a link may be split across multiple +/// [TextSpan]s. +/// +/// For example, consider the [TextSpan]s +/// `[TextSpan(text: 'http://'), TextSpan(text: 'google.com'), TextSpan(text: '/')]`. +/// This builder would be called three times, with the following parameters: +/// +/// 1. `displayString: 'http://', linkString: 'http://google.com/'` +/// 2. `displayString: 'google.com', linkString: 'http://google.com/'` +/// 3. `displayString: '/', linkString: 'http://google.com/'` +/// +/// {@template flutter.painting.LinkBuilder.recognizer} +/// It's necessary for the owning widget to manage the lifecycle of any +/// [GestureRecognizer]s created in this function, such as for handling a tap on +/// the link. See [TextSpan.recognizer] for more. +/// {@endtemplate} +/// +/// {@tool dartpad} +/// This example shows how to use [TextLinker] to link both URLs and Twitter +/// handles in a [TextSpan] tree. It also illustrates the difference between +/// `displayString` and `linkString`. +/// +/// ** See code in examples/api/lib/painting/text_linker/text_linker.1.dart ** +/// {@end-tool} +typedef InlineLinkBuilder = InlineSpan Function( + String displayString, + String linkString, +); + +/// Specifies a way to find and style parts of some text. +/// +/// [TextLinker]s can be applied to some text using the [linkSpans] method. +/// +/// {@tool dartpad} +/// This example shows how to use [TextLinker] to link both URLs and Twitter +/// handles in the same text. +/// +/// ** See code in examples/api/lib/painting/text_linker/text_linker.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to use [TextLinker] to link both URLs and Twitter +/// handles in a [TextSpan] tree instead of a flat string. +/// +/// ** See code in examples/api/lib/painting/text_linker/text_linker.1.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [LinkedText.textLinkers], which uses [TextLinker]s to allow full control +/// over matching and building different types of links. +/// * [LinkedText.new], which is simpler than using [TextLinker] and +/// automatically manages the lifecycle of any [GestureRecognizer]s. +class TextLinker { + /// Creates an instance of [TextLinker] with a [RegExp] and an [InlineLinkBuilder + /// [InlineLinkBuilder]. + /// + /// Does not manage the lifecycle of any [GestureRecognizer]s created in the + /// [InlineLinkBuilder], so it's the responsibility of the caller to do so. + /// See [TextSpan.recognizer] for more. + TextLinker({ + required this.regExp, + required this.linkBuilder, + }); + + /// Builds an [InlineSpan] to display the text that it's passed. + /// + /// {@macro flutter.painting.LinkBuilder.recognizer} + final InlineLinkBuilder linkBuilder; + + /// Matches text that should be turned into a link with [linkBuilder]. + final RegExp regExp; + + /// Applies the given [TextLinker]s to the given [InlineSpan]s and returns the + /// new resulting spans and any created [GestureRecognizer]s. + static Iterable linkSpans(Iterable spans, Iterable textLinkers) { + final _LinkedSpans linkedSpans = _LinkedSpans( + spans: spans, + textLinkers: textLinkers, + ); + return linkedSpans.linkedSpans; + } + + // Turns all matches from the regExp into a list of TextRanges. + static Iterable _textRangesFromText(String text, RegExp regExp) { + final Iterable matches = regExp.allMatches(text); + return matches.map((RegExpMatch match) { + return TextRange( + start: match.start, + end: match.end, + ); + }); + } + + /// Apply this [TextLinker] to a [String]. + Iterable<_TextLinkerMatch> _link(String text) { + final Iterable textRanges = _textRangesFromText(text, regExp); + return textRanges.map((TextRange textRange) { + return _TextLinkerMatch( + textRange: textRange, + linkBuilder: linkBuilder, + linkString: text.substring(textRange.start, textRange.end), + ); + }); + } + + @override + String toString() => '${objectRuntimeType(this, 'TextLinker')}($regExp)'; +} + +/// A matched replacement on some string. +/// +/// Produced by applying a [TextLinker]'s [RegExp] to a string. +class _TextLinkerMatch { + _TextLinkerMatch({ + required this.textRange, + required this.linkBuilder, + required this.linkString, + }) : assert(textRange.end - textRange.start == linkString.length); + + final InlineLinkBuilder linkBuilder; + final TextRange textRange; + + /// The string that [textRange] matches. + final String linkString; + + /// Get all [_TextLinkerMatch]s obtained from applying the given + /// `textLinker`s with the given `text`. + static List<_TextLinkerMatch> fromTextLinkers(Iterable textLinkers, String text) { + return textLinkers + .fold>( + <_TextLinkerMatch>[], + (List<_TextLinkerMatch> previousValue, TextLinker value) { + return previousValue..addAll(value._link(text)); + }); + } + + @override + String toString() => '${objectRuntimeType(this, '_TextLinkerMatch')}($textRange, $linkBuilder, $linkString)'; +} + +/// Used to cache information about a span's recursive text. +/// +/// Avoids repeatedly calling [TextSpan.toPlainText]. +class _TextCache { + factory _TextCache({ + required InlineSpan span, + }) { + if (span is! TextSpan) { + return _TextCache._( + text: '', + lengths: {span: 0}, + ); + } + + _TextCache childrenTextCache = _TextCache._empty(); + for (final InlineSpan child in span.children ?? []) { + final _TextCache childTextCache = _TextCache( + span: child, + ); + childrenTextCache = childrenTextCache._merge(childTextCache); + } + + final String text = (span.text ?? '') + childrenTextCache.text; + return _TextCache._( + text: text, + lengths: { + span: text.length, + ...childrenTextCache._lengths, + }, + ); + } + + factory _TextCache.fromMany({ + required Iterable spans, + }) { + _TextCache textCache = _TextCache._empty(); + for (final InlineSpan span in spans) { + final _TextCache spanTextCache = _TextCache( + span: span, + ); + textCache = textCache._merge(spanTextCache); + } + return textCache; + } + + _TextCache._empty( + ) : text = '', + _lengths = {}; + + const _TextCache._({ + required this.text, + required Map lengths, + }) : _lengths = lengths; + + /// The flattened text of all spans in the span tree. + final String text; + + /// A [Map] containing the lengths of all spans in the span tree. + /// + /// The length is defined as the length of the flattened text at the point in + /// the tree where the node resides. + /// + /// The length of [text] is the length of the root node in [_lengths]. + final Map _lengths; + + /// Merges the given _TextCache with this one by appending it to the end. + /// + /// Returns a new _TextCache and makes no modifications to either passed in. + _TextCache _merge(_TextCache other) { + return _TextCache._( + text: text + other.text, + lengths: Map.from(_lengths)..addAll(other._lengths), + ); + } + + int? getLength(InlineSpan span) => _lengths[span]; + + @override + String toString() => '${objectRuntimeType(this, '_TextCache')}($text, $_lengths)'; +} + +/// Signature for the output of linking an InlineSpan to some +/// _TextLinkerMatches. +typedef _LinkSpanRecursion = ( + /// The output of linking the input InlineSpan. + InlineSpan linkedSpan, + /// The provided _TextLinkerMatches, but with those completely used during + /// linking removed. + Iterable<_TextLinkerMatch> unusedTextLinkerMatches, +); + +/// Signature for the output of linking a List of InlineSpans to some +/// _TextLinkerMatches. +typedef _LinkSpansRecursion = ( + /// The output of linking the input InlineSpans. + Iterable linkedSpans, + /// The provided _TextLinkerMatches, but with those completely used during + /// linking removed. + Iterable<_TextLinkerMatch> unusedTextLinkerMatches, +); + +/// Applies some [TextLinker]s to some [InlineSpan]s and produces a new list of +/// [linkedSpans] as well as the [recognizers] created for each generated link. +class _LinkedSpans { + factory _LinkedSpans({ + required Iterable spans, + required Iterable textLinkers, + }) { + // Flatten the spans and store all string lengths, so that matches across + // span boundaries can be matched in the flat string. This is calculated + // once in the beginning to avoid recomputing. + final _TextCache textCache = _TextCache.fromMany(spans: spans); + + final Iterable<_TextLinkerMatch> textLinkerMatches = + _cleanTextLinkerMatches( + _TextLinkerMatch.fromTextLinkers(textLinkers, textCache.text), + ); + + final (Iterable linkedSpans, Iterable<_TextLinkerMatch> _) = + _linkSpansRecurse( + spans, + textCache, + textLinkerMatches, + ); + + return _LinkedSpans._( + linkedSpans: linkedSpans, + ); + } + + const _LinkedSpans._({ + required this.linkedSpans, + }); + + final Iterable linkedSpans; + + static List<_TextLinkerMatch> _cleanTextLinkerMatches(Iterable<_TextLinkerMatch> textLinkerMatches) { + final List<_TextLinkerMatch> nextTextLinkerMatches = textLinkerMatches.toList(); + + // Sort by start. + nextTextLinkerMatches.sort((_TextLinkerMatch a, _TextLinkerMatch b) { + return a.textRange.start.compareTo(b.textRange.start); + }); + + // Validate that there are no overlapping matches. + int lastEnd = 0; + for (final _TextLinkerMatch textLinkerMatch in nextTextLinkerMatches) { + if (textLinkerMatch.textRange.start < lastEnd) { + throw ArgumentError('Matches must not overlap. Overlapping text was "${textLinkerMatch.linkString}" located at ${textLinkerMatch.textRange.start}-${textLinkerMatch.textRange.end}.'); + } + lastEnd = textLinkerMatch.textRange.end; + } + + // Remove empty ranges. + nextTextLinkerMatches.removeWhere((_TextLinkerMatch textLinkerMatch) { + return textLinkerMatch.textRange.start == textLinkerMatch.textRange.end; + }); + + return nextTextLinkerMatches; + } + + // `index` is the index of the start of `span` in the overall flattened tree + // string. + static _LinkSpansRecursion _linkSpansRecurse(Iterable spans, _TextCache textCache, Iterable<_TextLinkerMatch> textLinkerMatches, [int index = 0]) { + final List output = []; + Iterable<_TextLinkerMatch> nextTextLinkerMatches = textLinkerMatches; + int nextIndex = index; + for (final InlineSpan span in spans) { + final (InlineSpan childSpan, Iterable<_TextLinkerMatch> childTextLinkerMatches) = _linkSpanRecurse( + span, + textCache, + nextTextLinkerMatches, + nextIndex, + ); + output.add(childSpan); + nextTextLinkerMatches = childTextLinkerMatches; + nextIndex += textCache.getLength(span)!; + } + + return (output, nextTextLinkerMatches); + } + + // `index` is the index of the start of `span` in the overall flattened tree + // string. + static _LinkSpanRecursion _linkSpanRecurse(InlineSpan span, _TextCache textCache, Iterable<_TextLinkerMatch> textLinkerMatches, [int index = 0]) { + if (span is! TextSpan) { + return (span, textLinkerMatches); + } + + final List nextChildren = []; + List<_TextLinkerMatch> nextTextLinkerMatches = <_TextLinkerMatch>[...textLinkerMatches]; + int lastLinkEnd = index; + if (span.text?.isNotEmpty ?? false) { + final int textEnd = index + span.text!.length; + for (final _TextLinkerMatch textLinkerMatch in textLinkerMatches) { + if (textLinkerMatch.textRange.start >= textEnd) { + // Because ranges is ordered, there are no more relevant ranges for this + // text. + break; + } + if (textLinkerMatch.textRange.end <= index) { + // This range ends before this span and is therefore irrelevant to it. + // It should have been removed from ranges. + assert(false, 'Invalid ranges.'); + nextTextLinkerMatches.removeAt(0); + continue; + } + if (textLinkerMatch.textRange.start > index) { + // Add the unlinked text before the range. + nextChildren.add(TextSpan( + text: span.text!.substring( + lastLinkEnd - index, + textLinkerMatch.textRange.start - index, + ), + )); + } + // Add the link itself. + final int linkStart = math.max(textLinkerMatch.textRange.start, index); + lastLinkEnd = math.min(textLinkerMatch.textRange.end, textEnd); + final InlineSpan nextChild = textLinkerMatch.linkBuilder( + span.text!.substring(linkStart - index, lastLinkEnd - index), + textLinkerMatch.linkString, + ); + nextChildren.add(nextChild); + if (textLinkerMatch.textRange.end > textEnd) { + // If we only partially used this range, keep it in nextRanges. Since + // overlapping ranges have been removed, this must be the last relevant + // range for this span. + break; + } + nextTextLinkerMatches.removeAt(0); + } + + // Add any extra text after any ranges. + final String remainingText = span.text!.substring(lastLinkEnd - index); + if (remainingText.isNotEmpty) { + nextChildren.add(TextSpan( + text: remainingText, + )); + } + } + + // Recurse on the children. + if (span.children?.isNotEmpty ?? false) { + final ( + Iterable childrenSpans, + Iterable<_TextLinkerMatch> childrenTextLinkerMatches, + ) = _linkSpansRecurse( + span.children!, + textCache, + nextTextLinkerMatches, + index + (span.text?.length ?? 0), + ); + nextTextLinkerMatches = childrenTextLinkerMatches.toList(); + nextChildren.addAll(childrenSpans); + } + + return ( + TextSpan( + style: span.style, + children: nextChildren, + ), + nextTextLinkerMatches, + ); + } +} diff --git a/packages/flutter/lib/src/widgets/linked_text.dart b/packages/flutter/lib/src/widgets/linked_text.dart new file mode 100644 index 0000000000000..9031adc0fc340 --- /dev/null +++ b/packages/flutter/lib/src/widgets/linked_text.dart @@ -0,0 +1,334 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/foundation.dart'; +import 'package:flutter/gestures.dart'; + +import 'basic.dart'; +import 'framework.dart'; +import 'text.dart'; + +/// Singature for a function that builds the [Widget] output by [LinkedText]. +/// +/// Typically a [Text.rich] containing a [TextSpan] whose children are the +/// [linkedSpans]. +typedef LinkedTextWidgetBuilder = Widget Function ( + BuildContext context, + Iterable linkedSpans, +); + +/// A widget that displays text with parts of it made interactive. +/// +/// By default, any URLs in the text are made interactive, and clicking one +/// calls the provided callback. +/// +/// Works with either a flat [String] (`text`) or a list of [InlineSpan]s +/// (`spans`). When using `spans`, only [TextSpan]s will be converted to links. +/// +/// {@tool dartpad} +/// This example shows how to create a [LinkedText] that turns URLs into +/// working links. +/// +/// ** See code in examples/api/lib/widgets/linked_text/linked_text.0.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to use [LinkedText] to link Twitter handles by +/// passing in a custom [RegExp]. +/// +/// ** See code in examples/api/lib/widgets/linked_text/linked_text.1.dart ** +/// {@end-tool} +/// +/// {@tool dartpad} +/// This example shows how to use [LinkedText] to link URLs in a TextSpan tree +/// instead of in a flat string. +/// +/// ** See code in examples/api/lib/widgets/linked_text/linked_text.2.dart ** +/// {@end-tool} +class LinkedText extends StatefulWidget { + /// Creates an instance of [LinkedText] from the given [text] or [spans], + /// turning any URLs into interactive links. + /// + /// See also: + /// + /// * [LinkedText.regExp], which matches based on any given [RegExp]. + /// * [LinkedText.textLinkers], which uses [TextLinker]s to allow full + /// control over matching and building different types of links. + LinkedText({ + super.key, + required ValueChanged onTapUri, + this.builder = _defaultBuilder, + List? spans, + String? text, + }) : assert((text == null) != (spans == null), 'Must specify exactly one to link: either text or spans.'), + spans = spans ?? [ + TextSpan( + text: text, + ), + ], + onTap = _getOnTap(onTapUri), + regExp = defaultUriRegExp, + textLinkers = null; + + /// Creates an instance of [LinkedText] from the given [text] or [spans], + /// turning anything matched by [regExp] into interactive links. + /// + /// {@tool dartpad} + /// This example shows how to use [LinkedText] to link Twitter handles by + /// passing in a custom [RegExp]. + /// + /// ** See code in examples/api/lib/widgets/linked_text/linked_text.1.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [LinkedText.new], which matches [Uri]s. + /// * [LinkedText.textLinkers], which uses [TextLinker]s to allow full + /// control over matching and building different types of links. + LinkedText.regExp({ + super.key, + required this.onTap, + required this.regExp, + this.builder = _defaultBuilder, + List? spans, + String? text, + }) : assert((text == null) != (spans == null), 'Must specify exactly one to link: either text or spans.'), + spans = spans ?? [ + TextSpan( + text: text, + ), + ], + textLinkers = null; + + /// Creates an instance of [LinkedText] where the given [textLinkers] are + /// applied. + /// + /// Useful for independently matching different types of strings with + /// different behaviors. For example, highlighting both URLs and Twitter + /// handles with different style and/or behavior. + /// + /// {@tool dartpad} + /// This example shows how to use [LinkedText] to link both URLs and Twitter + /// handles in the same text. + /// + /// ** See code in examples/api/lib/widgets/linked_text/linked_text.3.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [LinkedText.new], which matches [Uri]s. + /// * [LinkedText.regExp], which matches based on any given [RegExp]. + LinkedText.textLinkers({ + super.key, + this.builder = _defaultBuilder, + String? text, + List? spans, + required List textLinkers, + }) : assert((text == null) != (spans == null), 'Must specify exactly one to link: either text or spans.'), + assert(textLinkers.isNotEmpty), + textLinkers = textLinkers, // ignore: prefer_initializing_formals + spans = spans ?? [ + TextSpan( + text: text, + ), + ], + onTap = null, + regExp = null; + + /// The spans on which to create links. + /// + /// It's also possible to specify a plain string by using the `text` + /// parameter instead. + final List spans; + + /// Builds the [Widget] that is output by [LinkedText]. + /// + /// By default, builds a [Text.rich] with a single [TextSpan] whose children + /// are the linked [TextSpan]s, and whose style is [DefaultTextStyle]. + final LinkedTextWidgetBuilder builder; + + /// Handles tapping on a link. + /// + /// This is irrelevant when using [LinkedText.textLinkers], where this is + /// controlled with an [InlineLinkBuilder] instead. + final ValueChanged? onTap; + + /// Matches the text that should be turned into a link. + /// + /// This is irrelevant when using [LinkedText.textLinkers], where each + /// [TextLinker] specifies its own [TextLinker.regExp]. + /// + /// {@tool dartpad} + /// This example shows how to use [LinkedText] to link Twitter handles by + /// passing in a custom [RegExp]. + /// + /// ** See code in examples/api/lib/widgets/linked_text/linked_text.1.dart ** + /// {@end-tool} + final RegExp? regExp; + + /// Defines what parts of the text to match and how to link them. + /// + /// [TextLinker]s are applied in the order given. Overlapping matches are not + /// supported and will produce an error. + /// + /// {@tool dartpad} + /// This example shows how to use [LinkedText] to link both URLs and Twitter + /// handles in the same text with [TextLinker]s. + /// + /// ** See code in examples/api/lib/widgets/linked_text/linked_text.3.dart ** + /// {@end-tool} + final List? textLinkers; + + /// The default [RegExp], which matches [Uri]s by default. + /// + /// Matches with and without a host, but only "http" or "https". Ignores email + /// addresses. + static final RegExp defaultUriRegExp = RegExp(r'(? given a callback specifically for + /// tapping on a [Uri]. + static ValueChanged _getOnTap(ValueChanged onTapUri) { + return (String linkString) { + Uri uri = Uri.parse(linkString); + if (uri.host.isEmpty) { + // defaultUriRegExp matches Uris without a host, but packages like + // url_launcher require a host to launch a Uri. So add the host. + uri = Uri.parse('https://$linkString'); + } + onTapUri(uri); + }; + } + + /// The default value of [builder]. + /// + /// Builds a [Text.rich] with a single [TextSpan] whose children are the + /// linked [TextSpan]s, and whose style is [DefaultTextStyle]. If there are no + /// linked [TextSpan]s to display, builds a [SizedBox.shrink]. + static Widget _defaultBuilder(BuildContext context, Iterable linkedSpans) { + if (linkedSpans.isEmpty) { + return const SizedBox.shrink(); + } + + return Text.rich( + TextSpan( + style: DefaultTextStyle.of(context).style, + children: linkedSpans.toList(), + ), + ); + } + + /// The style used for the link by default if none is given. + @visibleForTesting + static TextStyle defaultLinkStyle = _InlineLinkSpan.defaultLinkStyle; + + @override + State createState() => _LinkedTextState(); +} + +class _LinkedTextState extends State { + final List _recognizers = []; + late Iterable _linkedSpans; + late final List _textLinkers; + + void _disposeRecognizers() { + for (final GestureRecognizer recognizer in _recognizers) { + recognizer.dispose(); + } + _recognizers.clear(); + } + + void _linkSpans() { + _disposeRecognizers(); + final Iterable linkedSpans = TextLinker.linkSpans( + widget.spans, + _textLinkers, + ); + _linkedSpans = linkedSpans; + } + + @override + void initState() { + super.initState(); + _textLinkers = widget.textLinkers ?? [ + TextLinker( + regExp: widget.regExp ?? LinkedText.defaultUriRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () => widget.onTap!(linkString); + // Keep track of created recognizers so that they can be disposed. + _recognizers.add(recognizer); + return _InlineLinkSpan( + recognizer: recognizer, + style: LinkedText.defaultLinkStyle, + text: displayString, + ); + }, + ), + ]; + _linkSpans(); + } + + @override + void didUpdateWidget(LinkedText oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.spans != oldWidget.spans || widget.textLinkers != oldWidget.textLinkers) { + _linkSpans(); + } + } + + @override + void dispose() { + _disposeRecognizers(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.builder(context, _linkedSpans); + } +} + +/// An inline, interactive text link. +/// +/// See also: +/// +/// * [LinkedText], which creates links with this class by default. +class _InlineLinkSpan extends TextSpan { + /// Create an instance of [_InlineLinkSpan]. + _InlineLinkSpan({ + required String text, + TextStyle? style, + super.recognizer, + }) : super( + style: style ?? defaultLinkStyle, + mouseCursor: SystemMouseCursors.click, + text: text, + ); + + static Color get _linkColor { + return switch (defaultTargetPlatform) { + // This value was taken from Safari on an iPhone 14 Pro iOS 16.4 + // simulator. + TargetPlatform.iOS => const Color(0xff1717f0), + // This value was taken from Chrome on macOS 13.4.1. + TargetPlatform.macOS => const Color(0xff0000ee), + // This value was taken from Chrome on Android 14. + TargetPlatform.android || TargetPlatform.fuchsia => const Color(0xff0e0eef), + // This value was taken from the Chrome browser running on GNOME 43.3 on + // Debian. + TargetPlatform.linux => const Color(0xff0026e8), + // This value was taken from the Edge browser running on Windows 10. + TargetPlatform.windows => const Color(0xff1e2b8b), + }; + } + + /// The style used for the link by default if none is given. + @visibleForTesting + static TextStyle defaultLinkStyle = TextStyle( + color: _linkColor, + decorationColor: _linkColor, + decoration: TextDecoration.underline, + ); +} diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index cc188608a8519..7c55f76192286 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -73,6 +73,7 @@ export 'src/widgets/inherited_theme.dart'; export 'src/widgets/interactive_viewer.dart'; export 'src/widgets/keyboard_listener.dart'; export 'src/widgets/layout_builder.dart'; +export 'src/widgets/linked_text.dart'; export 'src/widgets/list_wheel_scroll_view.dart'; export 'src/widgets/localizations.dart'; export 'src/widgets/lookup_boundary.dart'; diff --git a/packages/flutter/test/painting/text_linker_test.dart b/packages/flutter/test/painting/text_linker_test.dart new file mode 100644 index 0000000000000..aee0a5fea1659 --- /dev/null +++ b/packages/flutter/test/painting/text_linker_test.dart @@ -0,0 +1,322 @@ +// Copyright 2014 The Flutter Authors. All rights 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() { + final RegExp hashTagRegExp = RegExp(r'#[a-zA-Z0-9]*'); + final RegExp urlRegExp = RegExp(r'(?[ + 'https://www.example.com', + 'www.example123.co.uk', + 'subdomain.example.net', + 'ftp.subdomain.example.net', + 'http://subdomain.example.net', + 'https://subdomain.example.net', + 'http://example.com/', + 'https://www.example.org/', + 'ftp.subdomain.example.net', + 'example.com', + 'subdomain.example.io', + 'www.example123.co.uk', + 'http://example.com:8080/', + 'https://www.example.com/path/to/resource', + 'http://www.example.com/index.php?query=test#fragment', + 'https://subdomain.example.io:8443/resource/file.html?search=query#result', + 'example.com', + 'subsub.www.example.com', + 'https://subsub.www.example.com' + ]) { + test('converts the valid url $text to a link by default', () { + final Iterable linkedSpans = TextLinker.linkSpans( + [ + TextSpan( + text: text, + ), + ], + [ + TextLinker( + regExp: LinkedText.defaultUriRegExp, + linkBuilder: (String displayString, String linkString) { + return TextSpan( + style: LinkedText.defaultLinkStyle, + text: displayString, + ); + }, + ), + ], + ); + + expect(linkedSpans, hasLength(1)); + expect(linkedSpans.first, isA()); + + final TextSpan wrapperSpan = linkedSpans.first as TextSpan; + expect(wrapperSpan.text, isNull); + expect(wrapperSpan.children, hasLength(1)); + + final TextSpan span = wrapperSpan.children!.first as TextSpan; + + expect(span.text, text); + expect(span.style, LinkedText.defaultLinkStyle); + expect(span.children, isNull); + }); + } + + for (final String text in [ + 'abcd://subdomain.example.net', + 'ftp://subdomain.example.net', + ]) { + test('does nothing to the invalid url $text', () { + final Iterable linkedSpans = TextLinker.linkSpans( + [ + TextSpan( + text: text, + ), + ], + [ + TextLinker( + regExp: LinkedText.defaultUriRegExp, + linkBuilder: (String displayString, String linkString) { + return TextSpan( + text: displayString, + ); + }, + ), + ], + ); + + expect(linkedSpans, hasLength(1)); + expect(linkedSpans.first, isA()); + + final TextSpan wrapperSpan = linkedSpans.first as TextSpan; + expect(wrapperSpan.text, isNull); + expect(wrapperSpan.children, hasLength(1)); + + final TextSpan span = wrapperSpan.children!.first as TextSpan; + + expect(span.text, text); + expect(span.style, isNull); + expect(span.children, isNull); + }); + } + + for (final String text in [ + '"example.com"', + "'example.com'", + '(example.com)', + ]) { + test('can parse url $text with leading and trailing characters', () { + final Iterable linkedSpans = TextLinker.linkSpans( + [ + TextSpan( + text: text, + ), + ], + [ + TextLinker( + regExp: LinkedText.defaultUriRegExp, + linkBuilder: (String displayString, String linkString) { + return TextSpan( + style: LinkedText.defaultLinkStyle, + text: displayString, + ); + }, + ), + ], + ); + + expect(linkedSpans, hasLength(1)); + expect(linkedSpans.first, isA()); + + final TextSpan wrapperSpan = linkedSpans.first as TextSpan; + expect(wrapperSpan.text, isNull); + expect(wrapperSpan.children, hasLength(3)); + + expect(wrapperSpan.children!.first, isA()); + final TextSpan leadingSpan = wrapperSpan.children!.first as TextSpan; + expect(leadingSpan.text, hasLength(1)); + expect(leadingSpan.style, isNull); + expect(leadingSpan.children, isNull); + + expect(wrapperSpan.children![1], isA()); + final TextSpan bodySpan = wrapperSpan.children![1] as TextSpan; + expect(bodySpan.text, 'example.com'); + expect(bodySpan.style, LinkedText.defaultLinkStyle); + expect(bodySpan.children, isNull); + + expect(wrapperSpan.children!.last, isA()); + final TextSpan trailingSpan = wrapperSpan.children!.last as TextSpan; + expect(trailingSpan.text, hasLength(1)); + expect(trailingSpan.style, isNull); + expect(trailingSpan.children, isNull); + }); + } + }); + + test('multiple TextLinkers', () { + final TextLinker urlTextLinker = TextLinker( + regExp: urlRegExp, + linkBuilder: (String displayString, String linkString) { + return TextSpan( + style: LinkedText.defaultLinkStyle, + text: displayString, + ); + }, + ); + final TextLinker hashTagTextLinker = TextLinker( + regExp: hashTagRegExp, + linkBuilder: (String displayString, String linkString) { + return TextSpan( + style: LinkedText.defaultLinkStyle, + text: displayString, + ); + }, + ); + final Iterable linkedSpans = TextLinker.linkSpans( + [ + const TextSpan( + text: 'Flutter is great #crossplatform #declarative check out flutter.dev.', + ), + ], + [urlTextLinker, hashTagTextLinker], + ); + + expect(linkedSpans, hasLength(1)); + expect(linkedSpans.first, isA()); + + final TextSpan wrapperSpan = linkedSpans.first as TextSpan; + expect(wrapperSpan.text, isNull); + expect(wrapperSpan.children, hasLength(7)); + + expect(wrapperSpan.children!.first, isA()); + final TextSpan textSpan1 = wrapperSpan.children!.first as TextSpan; + expect(textSpan1.text, 'Flutter is great '); + expect(textSpan1.style, isNull); + expect(textSpan1.children, isNull); + + expect(wrapperSpan.children![1], isA()); + final TextSpan hashTagSpan1 = wrapperSpan.children![1] as TextSpan; + expect(hashTagSpan1.text, '#crossplatform'); + expect(hashTagSpan1.style, LinkedText.defaultLinkStyle); + expect(hashTagSpan1.children, isNull); + + expect(wrapperSpan.children![2], isA()); + final TextSpan textSpan2 = wrapperSpan.children![2] as TextSpan; + expect(textSpan2.text, ' '); + expect(textSpan2.style, isNull); + expect(textSpan2.children, isNull); + + expect(wrapperSpan.children![3], isA()); + final TextSpan hashTagSpan2 = wrapperSpan.children![3] as TextSpan; + expect(hashTagSpan2.text, '#declarative'); + expect(hashTagSpan2.style, LinkedText.defaultLinkStyle); + expect(hashTagSpan2.children, isNull); + + expect(wrapperSpan.children![4], isA()); + final TextSpan textSpan3 = wrapperSpan.children![4] as TextSpan; + expect(textSpan3.text, ' check out '); + expect(textSpan3.style, isNull); + expect(textSpan3.children, isNull); + + expect(wrapperSpan.children![5], isA()); + final TextSpan urlSpan = wrapperSpan.children![5] as TextSpan; + expect(urlSpan.text, 'flutter.dev'); + expect(urlSpan.style, LinkedText.defaultLinkStyle); + expect(urlSpan.children, isNull); + + expect(wrapperSpan.children![6], isA()); + final TextSpan textSpan4 = wrapperSpan.children![6] as TextSpan; + expect(textSpan4.text, '.'); + expect(textSpan4.style, isNull); + expect(textSpan4.children, isNull); + }); + + test('complex span tree', () { + final Iterable linkedSpans = TextLinker.linkSpans( + const [ + TextSpan( + text: 'Check out https://www.', + children: [ + TextSpan( + style: TextStyle( + fontWeight: FontWeight.w800, + ), + text: 'flutter', + ), + ], + ), + TextSpan( + text: '.dev!', + ), + ], + [ + TextLinker( + regExp: LinkedText.defaultUriRegExp, + linkBuilder: (String displayString, String linkString) { + return TextSpan( + style: LinkedText.defaultLinkStyle, + text: displayString, + ); + }, + ), + ], + ); + + expect(linkedSpans, hasLength(2)); + + expect(linkedSpans.first, isA()); + final TextSpan span1 = linkedSpans.first as TextSpan; + expect(span1.text, isNull); + expect(span1.style, isNull); + expect(span1.children, hasLength(3)); + + // First span's children ('Check out https://www.flutter'). + expect(span1.children![0], isA()); + final TextSpan span1Child1 = span1.children![0] as TextSpan; + expect(span1Child1.text, 'Check out '); + expect(span1Child1.style, isNull); + expect(span1Child1.children, isNull); + + expect(span1.children![1], isA()); + final TextSpan span1Child2 = span1.children![1] as TextSpan; + expect(span1Child2.text, 'https://www.'); + expect(span1Child2.style, LinkedText.defaultLinkStyle); + expect(span1Child2.children, isNull); + + expect(span1.children![2], isA()); + final TextSpan span1Child3 = span1.children![2] as TextSpan; + expect(span1Child3.text, null); + expect(span1Child3.style, const TextStyle(fontWeight: FontWeight.w800)); + expect(span1Child3.children, hasLength(1)); + + expect(span1Child3.children![0], isA()); + final TextSpan span1Child3Child1 = span1Child3.children![0] as TextSpan; + expect(span1Child3Child1.text, 'flutter'); + expect(span1Child3Child1.style, LinkedText.defaultLinkStyle); + expect(span1Child3Child1.children, isNull); + + // Second span's children ('.dev!'). + expect(linkedSpans.elementAt(1), isA()); + final TextSpan span2 = linkedSpans.elementAt(1) as TextSpan; + expect(span2.text, isNull); + expect(span2.children, hasLength(2)); + expect(span2.style, isNull); + + expect(span2.children![0], isA()); + final TextSpan span2Child1 = span2.children![0] as TextSpan; + expect(span2Child1.text, '.dev'); + expect(span2Child1.style, LinkedText.defaultLinkStyle); + expect(span2Child1.children, isNull); + + expect(span2.children![1], isA()); + final TextSpan span2Child2 = span2.children![1] as TextSpan; + expect(span2Child2.text, '!'); + expect(span2Child2.children, isNull); + }); + }); +} diff --git a/packages/flutter/test/widgets/linked_text_test.dart b/packages/flutter/test/widgets/linked_text_test.dart new file mode 100644 index 0000000000000..9a1b2ab09c55d --- /dev/null +++ b/packages/flutter/test/widgets/linked_text_test.dart @@ -0,0 +1,367 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final RegExp hashTagRegExp = RegExp(r'#[a-zA-Z0-9]*'); + final RegExp urlRegExp = RegExp(r'(? recognizers = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return LinkedText.textLinkers( + textLinkers: [ + TextLinker( + regExp: hashTagRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () { + lastTappedLink = linkString; + }; + recognizers.add(recognizer); + return TextSpan( + style: LinkedText.defaultLinkStyle, + text: displayString, + recognizer: recognizer, + ); + }, + ), + ], + text: 'Flutter is great #crossplatform #declarative', + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + expect(lastTappedLink, isNull); + + await tester.tapAt(tester.getCenter(find.byType(RichText))); + expect(lastTappedLink, '#crossplatform'); + + expect(recognizers, hasLength(2)); + for (final TapGestureRecognizer recognizer in recognizers) { + recognizer.dispose(); + } + }); + + testWidgets('can link multiple different types', (WidgetTester tester) async { + String? lastTappedLink; + final List recognizers = []; + final TextLinker urlTextLinker = TextLinker( + regExp: urlRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () { + lastTappedLink = linkString; + }; + recognizers.add(recognizer); + return TextSpan( + style: LinkedText.defaultLinkStyle, + text: displayString, + recognizer: recognizer, + ); + }, + ); + final TextLinker hashTagTextLinker = TextLinker( + regExp: hashTagRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () { + lastTappedLink = linkString; + }; + recognizers.add(recognizer); + return TextSpan( + style: LinkedText.defaultLinkStyle, + text: displayString, + recognizer: recognizer, + ); + }, + ); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return LinkedText.textLinkers( + textLinkers: [urlTextLinker, hashTagTextLinker], + text: 'flutter.dev is great #crossplatform #declarative', + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + expect(lastTappedLink, isNull); + + await tester.tapAt(tester.getTopLeft(find.byType(RichText))); + expect(lastTappedLink, 'flutter.dev'); + + await tester.tapAt(tester.getCenter(find.byType(RichText))); + expect(lastTappedLink, '#crossplatform'); + + expect(recognizers, hasLength(3)); + for (final TapGestureRecognizer recognizer in recognizers) { + recognizer.dispose(); + } + }); + + testWidgets('can customize linkBuilder', (WidgetTester tester) async { + String? lastTappedLink; + final List recognizers = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return LinkedText.textLinkers( + textLinkers: [ + TextLinker( + regExp: LinkedText.defaultUriRegExp, + linkBuilder: (String displayString, String linkString) { + final TapGestureRecognizer recognizer = TapGestureRecognizer() + ..onTap = () { + lastTappedLink = linkString; + }; + recognizers.add(recognizer); + return TextSpan( + recognizer: recognizer, + text: displayString, + mouseCursor: SystemMouseCursors.help, + ); + }, + ), + ], + text: 'Check out flutter.dev.', + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + expect(lastTappedLink, isNull); + + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse, pointer: 1); + await gesture.addPointer(location: tester.getCenter(find.byType(Scaffold))); + await tester.pump(); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.basic); + await gesture.moveTo(tester.getCenter(find.byType(RichText))); + expect(RendererBinding.instance.mouseTracker.debugDeviceActiveCursor(1), SystemMouseCursors.help); + + await tester.tapAt(tester.getCenter(find.byType(RichText))); + expect(lastTappedLink, 'flutter.dev'); + + expect(recognizers, hasLength(1)); + for (final TapGestureRecognizer recognizer in recognizers) { + recognizer.dispose(); + } + }); + + testWidgets('can take nested spans', (WidgetTester tester) async { + Uri? lastTappedUri; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return LinkedText( + onTapUri: (Uri uri) { + lastTappedUri = uri; + }, + spans: [ + TextSpan( + text: 'Check out fl', + style: DefaultTextStyle.of(context).style, + children: const [ + TextSpan( + text: 'u', + children: [ + TextSpan( + style: TextStyle( + fontWeight: FontWeight.w800, + ), + text: 'tt', + ), + TextSpan( + text: 'er', + ), + ], + ), + ], + ), + const TextSpan( + text: '.dev.', + ), + ], + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + expect(lastTappedUri, isNull); + + await tester.tapAt(tester.getCenter(find.byType(RichText))); + + // The https:// host is automatically added. + expect(lastTappedUri, Uri.parse('https://flutter.dev')); + }); + + testWidgets('can handle WidgetSpans', (WidgetTester tester) async { + Uri? lastTappedUri; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return LinkedText( + onTapUri: (Uri uri) { + lastTappedUri = uri; + }, + spans: [ + TextSpan( + text: 'Check out fl', + style: DefaultTextStyle.of(context).style, + children: const [ + TextSpan( + text: 'u', + children: [ + TextSpan( + style: TextStyle( + fontWeight: FontWeight.w800, + ), + text: 'tt', + ), + WidgetSpan( + child: FlutterLogo(), + ), + TextSpan( + text: 'er', + ), + ], + ), + ], + ), + const TextSpan( + text: '.dev.', + ), + ], + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + expect(lastTappedUri, isNull); + + await tester.tapAt(tester.getCenter(find.byType(RichText))); + + // The WidgetSpan is ignored, so a link is still produced even though it has + // a FlutterLogo in the middle of it. + expect(lastTappedUri, Uri.parse('https://flutter.dev')); + }); + + testWidgets('builds the widget specified by builder', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Builder( + builder: (BuildContext context) { + return LinkedText( + onTapUri: (Uri uri) {}, + text: 'Check out flutter.dev.', + builder: (BuildContext context, Iterable linkedSpans) { + return RichText( + textAlign: TextAlign.center, + text: TextSpan( + children: linkedSpans.toList(), + ), + ); + }, + ); + }, + ), + ), + ), + ); + + expect(find.byType(RichText), findsOneWidget); + final RichText richText = tester.widget(find.byType(RichText)); + expect(richText.textAlign, TextAlign.center); + }); +} From 77a5a5d3a8c1fa0fb0ef05238daa0335667a5657 Mon Sep 17 00:00:00 2001 From: Daco Harkes Date: Thu, 14 Sep 2023 08:42:41 +0200 Subject: [PATCH 15/16] Update plugin_ffi generated file to match FFIgen 9.0.0 (#134614) Template plugin_ffi uses FFIgen and generates both the FFIgen inputs and the generated file. We rolled FFIgen to 9.0.0 in https://github.com/flutter/flutter/pull/130494, which means a slight change to the generated file. * https://github.com/dart-lang/ffigen/issues/619 Note, because of https://github.com/flutter/flutter/issues/105695, we run the test on the FFIgen repo rather than on the flutter CI. --- .../plugin_ffi/lib/projectName_bindings_generated.dart.tmpl | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/flutter_tools/templates/plugin_ffi/lib/projectName_bindings_generated.dart.tmpl b/packages/flutter_tools/templates/plugin_ffi/lib/projectName_bindings_generated.dart.tmpl index cb21d861d25b1..11b9f06a22854 100644 --- a/packages/flutter_tools/templates/plugin_ffi/lib/projectName_bindings_generated.dart.tmpl +++ b/packages/flutter_tools/templates/plugin_ffi/lib/projectName_bindings_generated.dart.tmpl @@ -5,6 +5,7 @@ // AUTO GENERATED FILE, DO NOT EDIT. // // Generated by `package:ffigen`. +// ignore_for_file: type=lint import 'dart:ffi' as ffi; /// Bindings for `src/{{projectName}}.h`. From 58ba6c295d8ccf125366b7e871b1c41fe2255f75 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Thu, 14 Sep 2023 11:52:22 -0400 Subject: [PATCH 16/16] Roll Packages from 06cd9e967b9f to 275b76ccffa5 (1 revision) (#134734) https://github.com/flutter/packages/compare/06cd9e967b9f...275b76ccffa5 2023-09-13 kevmoo@users.noreply.github.com go_router_builder: support the latest pkg:analyzer (flutter/packages#4921) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC flutter-ecosystem@google.com,rmistry@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://bugs.chromium.org/p/skia/issues/entry?template=Autoroller+Bug Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- bin/internal/flutter_packages.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/flutter_packages.version b/bin/internal/flutter_packages.version index 3e7efced9baae..fab924a8ffcd1 100644 --- a/bin/internal/flutter_packages.version +++ b/bin/internal/flutter_packages.version @@ -1 +1 @@ -06cd9e967b9f17da3eea812a6f85394f62278aec +275b76ccffa56ec1bfe5bd94a6539fa09518eced