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/.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 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/bin/internal/engine.version b/bin/internal/engine.version index 03f0fa49f0a59..c7f0a1d004853 100644 --- a/bin/internal/engine.version +++ b/bin/internal/engine.version @@ -1 +1 @@ -5e671d5c90f9088c7cad498dc8ecfbab8d03336a +cd90cc8469fbc789e0b44adc79f330f8d4d2744c 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 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/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/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/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/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/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/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/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/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(); 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); } 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/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/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/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/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/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/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/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), 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 = { 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/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( 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); + }); +} 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 { 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/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/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`. 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; + } +} 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, 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