From d14d2505f3aa748450ae11ca0996743c2eebf99a Mon Sep 17 00:00:00 2001 From: Jason Simmons Date: Fri, 7 Mar 2025 18:50:23 +0000 Subject: [PATCH 01/21] [Impeller] Store the TextureGLES cached framebuffer object as a reactor handle (#164761) TextureGLES references may be owned by garbage collected objects. If a GC drops the last reference to a TextureGLES, then the TextureGLES destructor will run on a thread that does not have an EGL context. That will cause failures when the destructor tries to delete the cached FBO held by the TextureGLES. This PR replaces the raw FBO handle with a ReactorGLES untracked handle. The ReactorGLES will schedule deletion of the underlying FBO on a thread that can call GLES APIs. --- .../renderer/backend/gles/reactor_gles.cc | 2 +- .../renderer/backend/gles/reactor_gles.h | 2 +- .../renderer/backend/gles/render_pass_gles.cc | 22 ++++++++++++------- .../renderer/backend/gles/texture_gles.cc | 8 +++---- .../renderer/backend/gles/texture_gles.h | 8 +++---- 5 files changed, 24 insertions(+), 18 deletions(-) diff --git a/engine/src/flutter/impeller/renderer/backend/gles/reactor_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/reactor_gles.cc index 111388f400c6a..2cb47560a74ef 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/reactor_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/reactor_gles.cc @@ -200,7 +200,7 @@ bool ReactorGLES::RegisterCleanupCallback(const HandleGLES& handle, return false; } -HandleGLES ReactorGLES::CreateUntrackedHandle(HandleType type) { +HandleGLES ReactorGLES::CreateUntrackedHandle(HandleType type) const { FML_DCHECK(CanReactOnCurrentThread()); auto new_handle = HandleGLES::Create(type); std::optional gl_handle = diff --git a/engine/src/flutter/impeller/renderer/backend/gles/reactor_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/reactor_gles.h index a05dd22b8fb42..b3eff12bbf7e3 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/reactor_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/reactor_gles.h @@ -180,7 +180,7 @@ class ReactorGLES { /// creating/accessing the handle. /// @param type The type of handle to create. /// @return The reactor handle. - HandleGLES CreateUntrackedHandle(HandleType type); + HandleGLES CreateUntrackedHandle(HandleType type) const; //---------------------------------------------------------------------------- /// @brief Collect a reactor handle. diff --git a/engine/src/flutter/impeller/renderer/backend/gles/render_pass_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/render_pass_gles.cc index cbfd26f503bf5..e0d1d4a7fff79 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/render_pass_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/render_pass_gles.cc @@ -211,7 +211,6 @@ void RenderPassGLES::ResetGLState(const ProcTableGLES& gl) { } #endif // IMPELLER_DEBUG - GLuint fbo = GL_NONE; TextureGLES& color_gles = TextureGLES::Cast(*pass_data.color_attachment); const bool is_default_fbo = color_gles.IsWrapped(); @@ -222,14 +221,21 @@ void RenderPassGLES::ResetGLState(const ProcTableGLES& gl) { } } else { // Create and bind an offscreen FBO. - GLuint cached_fbo = color_gles.GetCachedFBO(); - if (cached_fbo != GL_NONE) { - fbo = cached_fbo; - gl.BindFramebuffer(GL_FRAMEBUFFER, fbo); + if (!color_gles.GetCachedFBO().IsDead()) { + auto fbo = reactor.GetGLHandle(color_gles.GetCachedFBO()); + if (!fbo.has_value()) { + return false; + } + gl.BindFramebuffer(GL_FRAMEBUFFER, fbo.value()); } else { - gl.GenFramebuffers(1u, &fbo); - color_gles.SetCachedFBO(fbo); - gl.BindFramebuffer(GL_FRAMEBUFFER, fbo); + HandleGLES cached_fbo = + reactor.CreateUntrackedHandle(HandleType::kFrameBuffer); + color_gles.SetCachedFBO(cached_fbo); + auto fbo = reactor.GetGLHandle(cached_fbo); + if (!fbo.has_value()) { + return false; + } + gl.BindFramebuffer(GL_FRAMEBUFFER, fbo.value()); if (!color_gles.SetAsFramebufferAttachment( GL_FRAMEBUFFER, TextureGLES::AttachmentType::kColor0)) { diff --git a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc index 39fe43e6b3f3b..7f6e9103353cb 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.cc @@ -219,8 +219,8 @@ TextureGLES::TextureGLES(std::shared_ptr reactor, // |Texture| TextureGLES::~TextureGLES() { reactor_->CollectHandle(handle_); - if (cached_fbo_ != GL_NONE) { - reactor_->GetProcTable().DeleteFramebuffers(1, &cached_fbo_); + if (!cached_fbo_.IsDead()) { + reactor_->CollectHandle(cached_fbo_); } } @@ -659,11 +659,11 @@ std::optional TextureGLES::GetSyncFence() const { return fence_; } -void TextureGLES::SetCachedFBO(GLuint fbo) { +void TextureGLES::SetCachedFBO(HandleGLES fbo) { cached_fbo_ = fbo; } -GLuint TextureGLES::GetCachedFBO() const { +const HandleGLES& TextureGLES::GetCachedFBO() const { return cached_fbo_; } diff --git a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.h index 5d7a18507af4f..c1feb9030e9b1 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/texture_gles.h @@ -136,10 +136,10 @@ class TextureGLES final : public Texture, /// /// The color0 texture used by the 2D renderer will use this texture /// object to store the associated FBO the first time it is used. - void SetCachedFBO(GLuint fbo); + void SetCachedFBO(HandleGLES fbo); - /// Retrieve the cached FBO object, or GL_NONE if there is no object. - GLuint GetCachedFBO() const; + /// Retrieve the cached FBO object, or a dead handle if there is no object. + const HandleGLES& GetCachedFBO() const; // Visible for testing. std::optional GetSyncFence() const; @@ -155,7 +155,7 @@ class TextureGLES final : public Texture, mutable std::bitset<6> slices_initialized_ = 0; const bool is_wrapped_; const std::optional wrapped_fbo_; - GLuint cached_fbo_ = GL_NONE; + HandleGLES cached_fbo_ = HandleGLES::DeadHandle(); bool is_valid_ = false; TextureGLES(std::shared_ptr reactor, From 647e5a1407c34a5755cacac03be290a7b91c34b7 Mon Sep 17 00:00:00 2001 From: Chris Bracken Date: Fri, 7 Mar 2025 12:34:20 -0800 Subject: [PATCH 02/21] Roll gn to 7a8aa3a08a13521336853a28c46537ec04338a2d (#164806) Roll to tip-of-tree prior to testing Swift toolchain for macOS/iOS. Issue: https://github.com/flutter/flutter/issues/144791 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [X] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [X] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [X] I signed the [CLA]. - [X] I listed at least one issue that this PR fixes in the description above. - [X] I updated/added relevant documentation (doc comments with `///`). - [X] I added new tests to check the change I am making, or this PR is [test-exempt]. - [X] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [X] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 5bc8ed942fc9a..b85a3dbab1989 100644 --- a/DEPS +++ b/DEPS @@ -632,7 +632,7 @@ deps = { 'packages': [ { 'package': 'gn/gn/${{platform}}', - 'version': 'git_revision:c97a86a72105f3328a540f5a5ab17d11989ab7dd' + 'version': 'git_revision:7a8aa3a08a13521336853a28c46537ec04338a2d' }, ], 'dep_type': 'cipd', From c0e6f90652aae9b4871dcd34f07ef28568e81bdc Mon Sep 17 00:00:00 2001 From: Jason Simmons Date: Fri, 7 Mar 2025 20:54:09 +0000 Subject: [PATCH 03/21] Use Python 3.12 to run the yapf formatter if no lower version is available (#164807) Python 3.12 still provides the dependencies required by yapf (such as lib2to3) --- engine/src/flutter/tools/yapf.sh | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/engine/src/flutter/tools/yapf.sh b/engine/src/flutter/tools/yapf.sh index ce651152971e8..6fab3445fcb47 100755 --- a/engine/src/flutter/tools/yapf.sh +++ b/engine/src/flutter/tools/yapf.sh @@ -43,12 +43,14 @@ if command -v python3.10 &> /dev/null; then PYTHON_EXEC="python3.10" elif command -v python3.11 &> /dev/null; then PYTHON_EXEC="python3.11" +elif command -v python3.12 &> /dev/null; then + PYTHON_EXEC="python3.12" else python3 -c " import sys version = sys.version_info -if (version.major, version.minor) > (3, 11): - print(f'Error: The yapf Python formatter requires Python version 3.11 or ' +if (version.major, version.minor) > (3, 12): + print(f'Error: The yapf Python formatter requires Python version 3.12 or ' f'earlier. The installed python3 version is ' f'{version.major}.{version.minor}.', file=sys.stderr) From b7bea22ab84f128fc45640fd8dba0e7acf07425a Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 7 Mar 2025 13:21:55 -0800 Subject: [PATCH 04/21] [Widget Inspector] Handle null exceptions calling `renderObject` (#163642) Fixes https://github.com/flutter/devtools/issues/8905 Based on the stacktrace in https://github.com/flutter/devtools/issues/8905: * This call to `renderObject` can throw a null-exception: https://github.com/flutter/flutter/blob/39b4951f8f0bb7a32532ee2f67e83a783b065b58/packages/flutter/lib/src/widgets/framework.dart#L3745 * That exception is thrown here: https://github.com/flutter/flutter/blob/39b4951f8f0bb7a32532ee2f67e83a783b065b58/packages/flutter/lib/src/widgets/framework.dart#L6534 I've been unable to figure out a way to get into a state that reproduces this. Instead, this PR simply handles the exception and returns `null` (because we already gracefully handle the case where the `renderObject` is `null`. --- .../lib/src/widgets/widget_inspector.dart | 18 ++++++++----- .../test/widgets/widget_inspector_test.dart | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index 9270c9091f5b7..6832e38b89aad 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -2068,7 +2068,7 @@ mixin WidgetInspectorService { } final Object? value = node.value; if (value is Element) { - final RenderObject? renderObject = value.renderObject; + final RenderObject? renderObject = _renderObjectOrNull(value); if (renderObject is RenderParagraph) { additionalPropertiesJson['textPreview'] = renderObject.text.toPlainText(); } @@ -2160,7 +2160,7 @@ mixin WidgetInspectorService { return null; } final RenderObject? renderObject = - object is Element ? object.renderObject : (object as RenderObject?); + object is Element ? _renderObjectOrNull(object) : (object as RenderObject?); if (renderObject == null || !renderObject.attached) { return null; } @@ -2224,7 +2224,7 @@ mixin WidgetInspectorService { InspectorSerializationDelegate delegate, ) { final Object? value = node.value; - final RenderObject? renderObject = value is Element ? value.renderObject : null; + final RenderObject? renderObject = value is Element ? _renderObjectOrNull(value) : null; if (renderObject == null) { return const {}; } @@ -2320,7 +2320,7 @@ mixin WidgetInspectorService { final Object? object = toObject(id); bool succeed = false; if (object != null && object is Element) { - final RenderObject? render = object.renderObject; + final RenderObject? render = _renderObjectOrNull(object); final ParentData? parentData = render?.parentData; if (parentData is FlexParentData) { parentData.fit = flexFit; @@ -2338,7 +2338,7 @@ mixin WidgetInspectorService { final dynamic object = toObject(id); bool succeed = false; if (object != null && object is Element) { - final RenderObject? render = object.renderObject; + final RenderObject? render = _renderObjectOrNull(object); final ParentData? parentData = render?.parentData; if (parentData is FlexParentData) { parentData.flex = factor; @@ -2362,7 +2362,7 @@ mixin WidgetInspectorService { final Object? object = toObject(id); bool succeed = false; if (object != null && object is Element) { - final RenderObject? render = object.renderObject; + final RenderObject? render = _renderObjectOrNull(object); if (render is RenderFlex) { render.mainAxisAlignment = mainAxisAlignment; render.crossAxisAlignment = crossAxisAlignment; @@ -2543,6 +2543,12 @@ mixin WidgetInspectorService { _clearStats(); _resetErrorCount(); } + + /// Safely get the render object of an [Element]. + /// + /// If the element is not yet mounted, the result will be null. + RenderObject? _renderObjectOrNull(Element element) => + element.mounted ? element.renderObject : null; } /// Accumulator for a count associated with a specific source location. diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart index 94034afbe5430..adfe355760e8c 100644 --- a/packages/flutter/test/widgets/widget_inspector_test.dart +++ b/packages/flutter/test/widgets/widget_inspector_test.dart @@ -4837,6 +4837,32 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { expect(parentData['offsetY'], equals('293.0')); }); + testWidgets( + 'ext.flutter.inspector.getLayoutExplorerNode does not throw for unmounted widget', + (WidgetTester tester) async { + // Mount the Row widget. + await pumpWidgetForLayoutExplorer(tester); + + // Get the id of the Row widget. + final Element rowElement = tester.element(find.byType(Row)); + service.setSelection(rowElement, group); + final String id = service.toId(rowElement, group)!; + + // Unmount the Row widget. + await tester.pumpWidget(const Placeholder()); + + // Verify that the call to getLayoutExplorerNode for the Row widget + // does not throw an exception. + expect( + () => service.testExtension( + WidgetInspectorServiceExtensions.getLayoutExplorerNode.name, + {'id': id, 'groupName': group, 'subtreeDepth': '1'}, + ), + returnsNormally, + ); + }, + ); + testWidgets('ext.flutter.inspector.getLayoutExplorerNode for RenderBox with FlexParentData', ( WidgetTester tester, ) async { From 711162887d2f81c5d2f19a63db63991dd412b581 Mon Sep 17 00:00:00 2001 From: John McDole Date: Fri, 7 Mar 2025 13:35:39 -0800 Subject: [PATCH 05/21] content-aware-hash experiment update (#164803) - output to annotations which can be used from a simple url - output summary to see if that's at all valuable --- .github/workflows/content-aware-hash.yml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/content-aware-hash.yml b/.github/workflows/content-aware-hash.yml index 6fd3572480801..3204c5090cdf3 100644 --- a/.github/workflows/content-aware-hash.yml +++ b/.github/workflows/content-aware-hash.yml @@ -7,7 +7,7 @@ name: Generate a content aware hash for the Flutter Engine on: workflow_dispatch jobs: - hash: + generate-engine-content-hash: runs-on: ubuntu-latest steps: - name: Checkout code @@ -19,4 +19,8 @@ jobs: - name: Generate Hash run: | - git ls-tree HEAD DEPS bin/internal/release-candidate-branch.version engine | git hash-object --stdin + engine_content_hash=$(git ls-tree HEAD DEPS bin/internal/release-candidate-branch.version engine | git hash-object --stdin) + # test notice annotation for retrival from api + echo "::notice ::{\"engine_content_hash\": \"${engine_content_hash}\"}" + # test summary writing + echo "{\"engine_content_hash\": \"${engine_content_hash}\"" >> $GITHUB_STEP_SUMMARY From 74a8d79e1434d916b801a7b8348aba253b3f0e4a Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Fri, 7 Mar 2025 14:53:26 -0800 Subject: [PATCH 06/21] [Impeller] dont redundantly set stencil reference on vulkan backend. (#164763) Cache the last stencil reference in RenderPassVK. If the requested stencil reference is set to the same value, don't update it on the cmd buffer. Hypothetical performance improvement, but easy to do. --- .../flutter/ci/licenses_golden/excluded_files | 1 + .../impeller/renderer/backend/vulkan/BUILD.gn | 1 + .../renderer/backend/vulkan/render_pass_vk.cc | 4 ++ .../renderer/backend/vulkan/render_pass_vk.h | 1 + .../vulkan/render_pass_vk_unittests.cc | 53 +++++++++++++++++++ 5 files changed, 60 insertions(+) create mode 100644 engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk_unittests.cc diff --git a/engine/src/flutter/ci/licenses_golden/excluded_files b/engine/src/flutter/ci/licenses_golden/excluded_files index 7cd7633870565..6e43c1045f00e 100644 --- a/engine/src/flutter/ci/licenses_golden/excluded_files +++ b/engine/src/flutter/ci/licenses_golden/excluded_files @@ -202,6 +202,7 @@ ../../../flutter/impeller/renderer/backend/vulkan/pipeline_cache_data_vk_unittests.cc ../../../flutter/impeller/renderer/backend/vulkan/render_pass_builder_vk_unittests.cc ../../../flutter/impeller/renderer/backend/vulkan/render_pass_cache_unittests.cc +../../../flutter/impeller/renderer/backend/vulkan/render_pass_vk_unittests.cc ../../../flutter/impeller/renderer/backend/vulkan/resource_manager_vk_unittests.cc ../../../flutter/impeller/renderer/backend/vulkan/swapchain/README.md ../../../flutter/impeller/renderer/backend/vulkan/swapchain/khr/README.md diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/BUILD.gn b/engine/src/flutter/impeller/renderer/backend/vulkan/BUILD.gn index 43b15f40dcc00..6e8b85e9f63c3 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/BUILD.gn +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/BUILD.gn @@ -19,6 +19,7 @@ impeller_component("vulkan_unittests") { "pipeline_cache_data_vk_unittests.cc", "render_pass_builder_vk_unittests.cc", "render_pass_cache_unittests.cc", + "render_pass_vk_unittests.cc", "resource_manager_vk_unittests.cc", "test/gpu_tracer_unittests.cc", "test/mock_vulkan.cc", diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk.cc index f946cf3571082..a770765a79b32 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk.cc @@ -340,6 +340,10 @@ void RenderPassVK::SetCommandLabel(std::string_view label) { // |RenderPass| void RenderPassVK::SetStencilReference(uint32_t value) { + if (current_stencil_ == value) { + return; + } + current_stencil_ = value; command_buffer_vk_.setStencilReference( vk::StencilFaceFlagBits::eVkStencilFrontAndBack, value); } diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk.h b/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk.h index 80d6d93f0c7b7..bc921f1077431 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk.h +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk.h @@ -34,6 +34,7 @@ class RenderPassVK final : public RenderPass { vk::CommandBuffer command_buffer_vk_; std::shared_ptr color_image_vk_; std::shared_ptr resolve_image_vk_; + uint32_t current_stencil_ = 0; // Per-command state. std::array image_workspace_; diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk_unittests.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk_unittests.cc new file mode 100644 index 0000000000000..e692f51beb8c1 --- /dev/null +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/render_pass_vk_unittests.cc @@ -0,0 +1,53 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "flutter/testing/testing.h" // IWYU pragma: keep +#include "gtest/gtest.h" +#include "impeller/core/formats.h" +#include "impeller/renderer/backend/vulkan/render_pass_builder_vk.h" +#include "impeller/renderer/backend/vulkan/render_pass_vk.h" +#include "impeller/renderer/backend/vulkan/test/mock_vulkan.h" +#include "impeller/renderer/render_target.h" +#include "vulkan/vulkan_enums.hpp" + +namespace impeller { +namespace testing { + +TEST(RenderPassVK, DoesNotRedundantlySetStencil) { + std::shared_ptr context = MockVulkanContextBuilder().Build(); + std::shared_ptr copy = context; + auto cmd_buffer = context->CreateCommandBuffer(); + + RenderTargetAllocator allocator(context->GetResourceAllocator()); + RenderTarget target = allocator.CreateOffscreenMSAA(*copy.get(), {1, 1}, 1); + + std::shared_ptr render_pass = + cmd_buffer->CreateRenderPass(target); + + // Stencil reference set once at buffer start. + auto called_functions = GetMockVulkanFunctions(context->GetDevice()); + EXPECT_EQ(std::count(called_functions->begin(), called_functions->end(), + "vkCmdSetStencilReference"), + 1); + + // Duplicate stencil ref is not replaced. + render_pass->SetStencilReference(0); + render_pass->SetStencilReference(0); + render_pass->SetStencilReference(0); + + called_functions = GetMockVulkanFunctions(context->GetDevice()); + EXPECT_EQ(std::count(called_functions->begin(), called_functions->end(), + "vkCmdSetStencilReference"), + 1); + + // Different stencil value is updated. + render_pass->SetStencilReference(1); + called_functions = GetMockVulkanFunctions(context->GetDevice()); + EXPECT_EQ(std::count(called_functions->begin(), called_functions->end(), + "vkCmdSetStencilReference"), + 2); +} + +} // namespace testing +} // namespace impeller From 8b29bc6d8a8e282bf023584096801d46e8df96e4 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Fri, 7 Mar 2025 18:21:32 -0500 Subject: [PATCH 07/21] Roll Skia from cbc7e99d6c2f to b29851b2ada6 (10 revisions) (#164812) https://skia.googlesource.com/skia.git/+log/cbc7e99d6c2f..b29851b2ada6 2025-03-07 fmalita@google.com [skottie] Roll lottie-samples to include new regression test assets 2025-03-07 bungeman@google.com Remove and de-duplicate Android FontMgr streams 2025-03-07 egdaniel@google.com Fix need query for copyOnWrite for dual-proxies Ganesh images. 2025-03-07 skia-autoroll@skia-public.iam.gserviceaccount.com Roll shaders-base from 81fa6c51b85b to 536def9c5709 2025-03-07 kjlubick@google.com Update documentation about rolling into Chromium 2025-03-07 skia-autoroll@skia-public.iam.gserviceaccount.com Roll skottie-base from 7b44b80c0fac to 9ee87e7f230f 2025-03-07 skia-autoroll@skia-public.iam.gserviceaccount.com Roll debugger-app-base from cc91ae26ecef to bfb2f80c0482 2025-03-07 skia-autoroll@skia-public.iam.gserviceaccount.com Roll jsfiddle-base from 18808c894e65 to b07c254904bc 2025-03-07 lukasza@chromium.org [rust png] Update `png` from 0.17.15 to 0.18.0-rc 2025-03-07 michaelludwig@google.com [skif] Fix device-to-layer bounds mapping If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC codefu@google.com,kjlubick@google.com,michaelludwig@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- engine/src/flutter/ci/licenses_golden/licenses_skia | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DEPS b/DEPS index b85a3dbab1989..d29523c04d0d3 100644 --- a/DEPS +++ b/DEPS @@ -14,7 +14,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': 'cbc7e99d6c2fc662924922d376f3d17603079cba', + 'skia_revision': 'b29851b2ada6ac6cb3e4fdf218266e47a1a877e6', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. diff --git a/engine/src/flutter/ci/licenses_golden/licenses_skia b/engine/src/flutter/ci/licenses_golden/licenses_skia index e4adb1cb98461..004f3ae4f938f 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_skia +++ b/engine/src/flutter/ci/licenses_golden/licenses_skia @@ -1,4 +1,4 @@ -Signature: 51c91851459b4c285ea9804ba3902991 +Signature: 0bb4f1825069e19201603029e8e24ae0 ==================================================================================================== LIBRARY: etc1 From 86c95e9e45848d88f113148e7221591f27463238 Mon Sep 17 00:00:00 2001 From: Matan Lurey Date: Fri, 7 Mar 2025 16:40:26 -0800 Subject: [PATCH 08/21] Add and link to `Infra-Triage.md`. (#164673) Based on our discussions internally, making this available externally so we can link to it more easily. --- docs/triage/Infra-Triage.md | 169 ++++++++++++++++++++++++++++++++++++ docs/triage/README.md | 6 +- 2 files changed, 170 insertions(+), 5 deletions(-) create mode 100644 docs/triage/Infra-Triage.md diff --git a/docs/triage/Infra-Triage.md b/docs/triage/Infra-Triage.md new file mode 100644 index 0000000000000..462966bccc6f8 --- /dev/null +++ b/docs/triage/Infra-Triage.md @@ -0,0 +1,169 @@ +# Flutter Infra Team Triage + +_Canonical Link: [flutter.dev/to/team-infra](https://flutter.dev/to/team-infra)._ + +This doc details how to triage and work on issues marked [`team-infra`][]. + +[`team-infra`]: https://github.com/flutter/flutter/issues?q=is%3Aissue%20state%3Aopen%20label%3Ateam-infra + +--- + +The _infrastructure_ sub-team works a bit differently than our externally +facing product, as it is producing (and maintaining) infrastructure _for_ +Flutter, which includes tools and services that are open source but are **not +supported for external use**. + +As a result, our process _differs_ from the general [issue hygiene](../contributing/issue_hygiene/) and [issue triage](README.md): + +- We [own](#ownership) _general_ infrastructure, and decline other requests +- We use [_priority_ labels](#priorities) to mean specific things +- We accept [contributions](#contributing) in a more limited fashion +- We [close issues](#we-prefer-closing-issues) we do not plan to address and + will not accept contributions on + +This process allows us to have a more organized handle on the number of open +issues potentially affecting the team's velocity, including critical components +like release health. + +Table of contents: + +- [Triage](#triage) +- [Ownership](#ownership) +- [Priorities](#priorities) + - [P0](#p0) + - [P1](#p1) + - [P2](#p2) + - [P3](#p3) +- [We prefer closing issues](#we-prefer-closing-issues) +- [Contributing](#contributing) +- [Communication](#communication) + - [How to contact us](#how-to-contact-us) + +## Triage + +Links: + +- [P0 list](https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3Ateam-infra+label%3AP0+sort%3Aupdated-asc) +- [Cocoon PRs](https://github.com/flutter/cocoon/pulls) +- [GoB CLs](https://flutter-review.googlesource.com/q/status:open+-is:wip) +- [Incoming issue list](https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3Ateam-infra%2Cfyi-infra+-label%3Atriaged-infra+no%3Aassignee+-label%3A%22will+need+additional+triage%22+sort%3Aupdated-asc) +- [Latest updated issue list](https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3Ateam-infra%2Cfyi-infra+sort%3Aupdated-desc) + +## Ownership + +The infra sub-team owns _general_ infrastructure that is often shared or used +across the Flutter project, but _not all_ testing and or tooling infrastructure; +that is, unless the tool is mentioned below, we may decline or direct you at +another sub-team: + +- General CI/CD issues affecting [flutter/flutter](https://github.com/flutter/flutter) + or [flutter/packages](https://github.com/flutter/packages) +- The [dashboard](https://flutter-dashboard.appspot.com/) +- Anything in [flutter/cocoon](https://github.com/flutter/cocoon), + [flutter/recipes](https://flutter.googlesource.com/recipes/), and + [flutter/infra](https://flutter.googlesource.com/infra/) +- _Some_ of the general infrastructure in [`dev/**`](../../dev) + +## Priorities + +Our prioritization is _similar_ to [team-wide priorities](../contributing/issue_hygiene/README.md#priorities), +but with a few more specifics. Unless you work _on_ the infra team, we ask you +do not add or change priority labels. + +### [P0](https://github.com/flutter/flutter/issues?q=state%3Aopen%20label%3Ateam-infra%20label%3AP0) + +An **emergency** that needs to be addressed ASAP as there is no reasonable +workaround. + +P0s are worked on actively, with an update shared with the core team at least +once a week, and supercede _all_ other priorities (i.e. are a "stop work" order +on other issues). + +Examples might include: + +- PRs cannot be submitted +- Updating a PR, or pushing blank commits, do not trigger presubmits +- A serious security or privacy vulnerability in a deployed release + +### [P1](https://github.com/flutter/flutter/issues?q=state%3Aopen%20label%3Ateam-infra%20label%3AP1) + +An important change that would significantly improve productivity for the team, +or significantly improve reliability of the infrastructure (causing less P0 and +P1 issues). + +If an issue has not been pre-aligned with the team, or does not have a sponsor +from another team that will be immediately responsible for a feature or bug fix, +then P1 is _not_ suitable. + +Examples might include: + +- PRs can only be submitted with workarounds +- Presubmits or postsubmits across the board have degraded in speed or + reliability + +### [P2](https://github.com/flutter/flutter/issues?q=state%3Aopen%20label%3Ateam-infra%20label%3AP2) + +A change we agree with, but do not have bandwidth for. + +An individual _could_ meaningfully make progress on this issue, and we would review it. If there are no volunteers, it may never be completed. + +_See also: [contributing](#contributing)._ + +### [P3](https://github.com/flutter/flutter/issues?q=state%3Aopen%20label%3Ateam-infra%20label%3AP3) + +A change we agree with, but would require significant maintenance. + +While an individual _could_ meaningfully make progress on this issue, we would +_not_ review and accept it, as the cost of maintaining it is beyond what we can +currently sustain. + +Our own team's discretion is used for what P3 issues are left open, and which +are [closed as not planned](#we-prefer-closing-issues). + +_See also: [contributions](#contributions)._ + +## We prefer closing issues + +[Unlike the external Flutter product](../contributing/issue_hygiene/README.md#closing-issues), +we do not accept contributions on all issues, and run the `team-infra` label more +like an operations team; that is, if an issue is unlikely to be addressed or +does not meet the [priorities criteria](#priorities) above, we often will close +the issue as _not planned_. + +An issue closed as _not planned_ does not mean the issue does not have validity, +or that a subsequent more fleshed out issue or request would get more attention, +it just represents the limited bandwidth and capability of the team responsible. + +We encourage you/your team to manage your own "wishlist" of items, which could +be in the format of a github issue (but _not_ tagged `team-infra`), a gist, +a github project, a Google doc, or another format, and to +[share it with us](#how-to-contact-us). + +_See also: [contributing](#contributing)._ + +## Contributing + +This sub-team has a more limited contributions policy than other parts of the +project, as we build and support tools that are **not supported** as part of the +Flutter product, including internal CI/CD and tooling. + +In general, [P2](#p2) issues are a great way to contribute, as they have already +been actively vetted as "this is important to us" and "we would accept a PR or +PRs that address this bug or feature request". + +For other issues, if you are part of the core Flutter team, please +[contact us](#how-to-contact-us). + +## Communication + +The team primarily uses GitHub and internal Google chat for communication, which +is unavailable to non-Google employees. For issues that are important to the +broader community, we use [Discord](https://discord.com/channels/608014603317936148/608116355836805126) +and [flutter-announce@](https://groups.google.com/g/flutter-announce) as needed. + +### How to contact us + +If you work at Google, see [go/flutter-infra-team](http://goto.google.com/flutter-infra-team). + +Otherwise, see [#hackers-infra](https://discord.com/channels/608014603317936148/608021351567065092) +on Discord. Note responses may be infrequent. diff --git a/docs/triage/README.md b/docs/triage/README.md index ba2888ff0492e..f269e0392a4cc 100644 --- a/docs/triage/README.md +++ b/docs/triage/README.md @@ -263,11 +263,7 @@ In addition, consider these issues that fall under another team's triage, but ar ### Infrastructure team (`team-infra`) -- [P0 list](https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3Ateam-infra+label%3AP0+sort%3Aupdated-asc) -- [Cocoon PRs](https://github.com/flutter/cocoon/pulls) -- [GoB CLs](https://flutter-review.googlesource.com/q/status:open+-is:wip) -- [Incoming issue list](https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3Ateam-infra%2Cfyi-infra+-label%3Atriaged-infra+no%3Aassignee+-label%3A%22will+need+additional+triage%22+sort%3Aupdated-asc) -- [Latest updated issue list](https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3Ateam-infra%2Cfyi-infra+sort%3Aupdated-desc) +See the [Flutter Infra Team Triage](./Infra-Triage.md) page. ### iOS and macOS platform team (`team-ios` and `team-macos`) From 3f90143391f7a4d69624cefcf61dd2080bc1a2d2 Mon Sep 17 00:00:00 2001 From: Camille Simon <43054281+camsim99@users.noreply.github.com> Date: Fri, 7 Mar 2025 18:37:37 -0800 Subject: [PATCH 09/21] Merge CHANGELOG for 3.29.1 stable release (#164743) Merges CHANGELOG from stable to master for 3.29.1 release. Note that I did reformat the candidate branch `CHANGELOG` since it appeared abnormal to me (https://github.com/flutter/flutter/blob/flutter-3.29-candidate.0/CHANGELOG.md, the 3.29 changes section and the 3.29.1 changes section). I can submit a PR to fix that if that was the right move. Might have to cherrypick it though. ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- CHANGELOG.md | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b37d57ed9cc81..a1ae7e2bd1a34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,32 @@ docs/releases/Hotfix-Documentation-Best-Practices.md INTERNAL NOTE --> +## Flutter 3.29 Changes + +### [3.29.1](https://github.com/flutter/flutter/releases/tag/3.29.1) + +- [flutter/163830](https://github.com/flutter/flutter/pull/163830) - Fix Tab linear and elastic animation blink. +- [flutter/164119](https://github.com/flutter/flutter/pull/164119) - Configuration changes to run test on macOS 14 for Flutter's CI. +- [flutter/164155](https://github.com/flutter/flutter/pull/164155) - Roll .ci.yaml changes into the LUCI configuration only when the master branch is updated. +- [flutter/164191](https://github.com/flutter/flutter/pull/164191) - Improve safaridriver launch process in Flutter's CI testing for web. +- [flutter/164193](https://github.com/flutter/flutter/pull/164193) - Provide guided error message when app crashes due to JIT restriction on iPhones. +- [flutter/164050](https://github.com/flutter/flutter/pull/164050) - Fixes test reorderable_list_test.dart failing for certain ordering seeds, such as 20250221. +- [flutter/163316](https://github.com/flutter/flutter/pull/163316) - Configuration changes to run test on macOS 14 for Flutter's CI. +- [flutter/163581](https://github.com/flutter/flutter/pull/163581) - Fix crash when using BackdropFilters in certain GLES drivers. +- [flutter/163616](https://github.com/flutter/flutter/pull/163616) - Disable Vulkan on known bad Xclipse GPU drivers for Android. +- [flutter/163666](https://github.com/flutter/flutter/pull/163666) - Always post new task during gesture dispatch to fix jittery scrolling on iOS devices. +- [flutter/163667](https://github.com/flutter/flutter/pull/163667) - Ensure that OpenGL "flipped" textures do not leak via texture readback. +- [flutter/163741](https://github.com/flutter/flutter/pull/163741) - Flutter tool respects tracked engine.version. +- [flutter/163754](https://github.com/flutter/flutter/pull/163754) - Fix text glitch when returning to foreground for Android. +- [flutter/163058](https://github.com/flutter/flutter/pull/163058) - Fixes jittery glyphs. +- [flutter/163201](https://github.com/flutter/flutter/pull/163201) - Fixes buttons with icons that ignore foregroundColor. +- [flutter/163265](https://github.com/flutter/flutter/pull/163265) - Disable Vulkan on known bad exynos SoCs for Android. +- [flutter/163261](https://github.com/flutter/flutter/pull/163261) - Fixes for Impeller DrawVertices issues involving snapshots with empty sizes. +- [flutter/163672](https://github.com/flutter/flutter/pull/163672) - Check for tracked engine.version before overriding. + +### [3.29.0](https://github.com/flutter/flutter/releases/tag/3.29.0) +Initial stable release. + ## Flutter 3.27 Changes ### [3.27.4](https://github.com/flutter/flutter/releases/tag/3.27.4) From 83781ae65c67f81cf16ce15209788fb3e5d00060 Mon Sep 17 00:00:00 2001 From: Tong Mu Date: Fri, 7 Mar 2025 18:39:09 -0800 Subject: [PATCH 10/21] RoundSuperellipse algorithm v3: Ultrawideband heuristic formula (#164755) This PR revises the algorithm for RoundSuperellipses, replacing the current "max ratio" approximation with an algorithm that works for ratios from 2.0 to infinity. The previous "max ratio" approximation, which replaces the middle of edges with straight lines when the ratio is above 2.3, turns out to produce results too close to classic RRects. After reexamining the shapes and more calculation, I discovered that the max-ratio approximation is flawed. Even squircles with with really high ratios (~100) have a significant part of the edges that must not be approximated by straight lines. The new version is much closer to native. ### Comparison Native: (Notice the long wedgy gap at the end of curves) Before PR: (Notice the short wedgy gap at the end of curves) After PR: Another example (after PR). Even though the rectangular RSE has ratios of around 4, there are still curvature near the middle section of edges, which can be identified with the help of antialias pixels. image ### Details I found that `n` has really good linearity towards larger ratios. image I also found a good candidate for the precomputed unknown (called `k_xJ`), which has a smooth curve at the beginning and almost straight line towards larger `n`, removing the need to cap the scope of application of the formula. image The algorithm for paths are also updated in a similar way and approximated the Bezier factors with heuristic formulae for bigger `n`s. I've also verified that the path deviates from the geometry by no more than 0.01% over the range of n [15, 100] Theoretically removing "stretch" should simplify the algorithms. Unfortunately I had to spend more lines to process cases of zero radii, which were conveniently handled by stretches. ## Pre-launch Checklist - [ ] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [ ] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [ ] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [ ] I signed the [CLA]. - [ ] I listed at least one issue that this PR fixes in the description above. - [ ] I updated/added relevant documentation (doc comments with `///`). - [ ] I added new tests to check the change I am making, or this PR is [test-exempt]. - [ ] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [ ] All existing and new tests are passing. If you need help, consider asking for advice on the #hackers-new channel on [Discord]. [Contributor Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview [Tree Hygiene]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md [test-exempt]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests [Flutter Style Guide]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md [Features we expect every widget to implement]: https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement [CLA]: https://cla.developers.google.com/ [flutter/tests]: https://github.com/flutter/tests [breaking change policy]: https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes [Discord]: https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md [Data Driven Fixes]: https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md --- .../geometry/round_superellipse_geometry.cc | 59 +++-- .../flutter/impeller/geometry/path_builder.cc | 67 ++++-- .../geometry/round_superellipse_param.cc | 207 ++++++++++-------- .../geometry/round_superellipse_param.h | 14 +- .../geometry/round_superellipse_unittests.cc | 28 +-- .../flutter/lib/src/cupertino/dialog.dart | 2 +- 6 files changed, 207 insertions(+), 170 deletions(-) diff --git a/engine/src/flutter/impeller/entity/geometry/round_superellipse_geometry.cc b/engine/src/flutter/impeller/entity/geometry/round_superellipse_geometry.cc index fddaea410f5f4..4dee1949cef7e 100644 --- a/engine/src/flutter/impeller/entity/geometry/round_superellipse_geometry.cc +++ b/engine/src/flutter/impeller/entity/geometry/round_superellipse_geometry.cc @@ -291,37 +291,38 @@ size_t DrawOctantSquareLikeSquircle(Point* output, if (reverse_and_flip) { transform = transform * kFlip; } + if (param.se_n < 2) { + // It's a square. + *output = transform * Point(param.se_a, param.se_a); + return 1; + } /* The following figure shows the first quadrant of a square-like rounded - * superellipse. The target arc consists of the "stretch" (AB), a - * superellipsoid arc (BJ), and a circular arc (JM). + * superellipse. The target arc consists a superellipsoid arc (AJ) and a + * circular arc (JM). * - * straight superelipse - * ↓ ↓ - * A B J circular arc - * ---------...._ ↙ - * | | / `⟍ M (where y=x) - * | | / ⟋ ⟍ - * | | / ⟋ \ - * | | / ⟋ | - * | | ᜱD | - * | | / | - * ↑ +----+ S | - * s | | | - * ↓ +----+---------------| A' + * superelipse + * A ↓ circular arc + * ---------...._J ↙ + * | / `⟍ M (where x=y) + * | / ⟋ ⟍ + * | / ⟋ \ + * | / ⟋ | + * | ᜱD | + * | ⟋ | + * | ⟋ | + * |⟋ | + * +--------------------| A' * O - * ← s → - * ←------ size/2 ------→ + * ←-------- a ---------→ */ Point* next = output; if (!reverse_and_flip) { - // Point A - *(next++) = transform * param.edge_mid; - // Arc [B, J) - next += DrawSuperellipsoidArc( - next, param.se_a, param.se_n, param.se_max_theta, reverse_and_flip, - transform * Matrix::MakeTranslation(param.se_center)); + // Arc [A, J) + next += + DrawSuperellipsoidArc(next, param.se_a, param.se_n, param.se_max_theta, + reverse_and_flip, transform); // Arc [J, M) next += DrawCircularArc( next, param.circle_start - param.circle_center, @@ -333,14 +334,12 @@ size_t DrawOctantSquareLikeSquircle(Point* output, next, param.circle_start - param.circle_center, param.circle_max_angle.radians, reverse_and_flip, transform * Matrix::MakeTranslation(param.circle_center)); - // Arc [J, B) - next += DrawSuperellipsoidArc( - next, param.se_a, param.se_n, param.se_max_theta, reverse_and_flip, - transform * Matrix::MakeTranslation(param.se_center)); - // Point B - *(next++) = transform * (param.se_center + Point{0, param.se_a}); + // Arc [J, A) + next += + DrawSuperellipsoidArc(next, param.se_a, param.se_n, param.se_max_theta, + reverse_and_flip, transform); // Point A - *(next++) = transform * param.edge_mid; + *(next++) = transform * Point(0, param.se_a); } return next - output; } diff --git a/engine/src/flutter/impeller/geometry/path_builder.cc b/engine/src/flutter/impeller/geometry/path_builder.cc index 30e45a85f1736..e80c11c423e4a 100644 --- a/engine/src/flutter/impeller/geometry/path_builder.cc +++ b/engine/src/flutter/impeller/geometry/path_builder.cc @@ -17,16 +17,18 @@ namespace { // Utility functions used to build a rounded superellipse. class RoundSuperellipseBuilder { public: - typedef std::function< - void(const Point&, const Point&, const Point&, const Point&)> - CubicAdder; + using CubicAdder = std::function< + void(const Point&, const Point&, const Point&, const Point&)>; + using PointAdder = std::function; // Create a builder. // // The resulting curves, which consists of cubic curves, are added by calling // `cubic_adder`. - explicit RoundSuperellipseBuilder(CubicAdder cubic_adder) - : cubic_adder_(std::move(cubic_adder)) {} + explicit RoundSuperellipseBuilder(CubicAdder cubic_adder, + PointAdder point_adder) + : cubic_adder_(std::move(cubic_adder)), + point_adder_(std::move(point_adder)) {} // Draws an arc representing 1/4 of a rounded superellipse. // @@ -37,6 +39,11 @@ class RoundSuperellipseBuilder { bool reverse) { auto transform = Matrix::MakeTranslateScale(param.signed_scale, param.offset); + if (param.top.se_n < 2 || param.right.se_n < 2) { + point_adder_(transform * + (param.top.offset + Point(param.top.se_a, param.top.se_a))); + return; + } if (!reverse) { AddOctant(param.top, /*reverse=*/false, /*flip=*/false, transform); AddOctant(param.right, /*reverse=*/true, /*flip=*/true, transform); @@ -49,7 +56,7 @@ class RoundSuperellipseBuilder { private: std::array SuperellipseArcPoints( const RoundSuperellipseParam::Octant& param) { - Point start = {param.se_center.x, param.edge_mid.y}; + Point start = {0, param.se_a}; const Point& end = param.circle_start; constexpr Point start_tangent = {1, 0}; Point circle_start_vector = param.circle_start - param.circle_center; @@ -126,24 +133,33 @@ class RoundSuperellipseBuilder { // on a rounded superellipse and are not for general purpose superellipses. std::array SuperellipseBezierFactors(Scalar n) { constexpr Scalar kPrecomputedVariables[][2] = { - /*n=2.000*/ {0.02927797, 0.05200645}, - /*n=2.050*/ {0.02927797, 0.05200645}, - /*n=2.100*/ {0.03288032, 0.06051731}, - /*n=2.150*/ {0.03719241, 0.06818433}, - /*n=2.200*/ {0.04009513, 0.07196947}, - /*n=2.250*/ {0.04504750, 0.07860258}, - /*n=2.300*/ {0.05038706, 0.08498836}, - /*n=2.350*/ {0.05580771, 0.09071105}, - /*n=2.400*/ {0.06002306, 0.09363976}, - /*n=2.450*/ {0.06630048, 0.09946086}, - /*n=2.500*/ {0.07200351, 0.10384857}}; - constexpr Scalar kNStepInverse = 20; // = 1 / 0.05 + /*n=2.0*/ {0.01339448, 0.05994973}, + /*n=3.0*/ {0.13664115, 0.13592082}, + /*n=4.0*/ {0.24545546, 0.14099516}, + /*n=5.0*/ {0.32353151, 0.12808021}, + /*n=6.0*/ {0.39093068, 0.11726264}, + /*n=7.0*/ {0.44847800, 0.10808278}, + /*n=8.0*/ {0.49817452, 0.10026175}, + /*n=9.0*/ {0.54105583, 0.09344429}, + /*n=10.0*/ {0.57812578, 0.08748984}, + /*n=11.0*/ {0.61050961, 0.08224722}, + /*n=12.0*/ {0.63903989, 0.07759639}, + /*n=13.0*/ {0.66416338, 0.07346530}, + /*n=14.0*/ {0.68675338, 0.06974996}, + /*n=15.0*/ {0.70678034, 0.06529512}}; constexpr size_t kNumRecords = sizeof(kPrecomputedVariables) / sizeof(kPrecomputedVariables[0]); + constexpr Scalar kStep = 1.00f; constexpr Scalar kMinN = 2.00f; + constexpr Scalar kMaxN = kMinN + (kNumRecords - 1) * kStep; + + if (n >= kMaxN) { + // Heuristic formula derived from fitting. + return {1.07f - expf(1.307649835) * powf(n, -0.8568516731), + -0.01f + expf(-0.9287690322) * powf(n, -0.6120901398)}; + } - Scalar steps = - std::clamp((n - kMinN) * kNStepInverse, 0, kNumRecords - 1); + Scalar steps = std::clamp((n - kMinN) / kStep, 0, kNumRecords - 1); size_t left = std::clamp(static_cast(std::floor(steps)), 0, kNumRecords - 2); Scalar frac = steps - left; @@ -155,6 +171,7 @@ class RoundSuperellipseBuilder { } CubicAdder cubic_adder_; + PointAdder point_adder_; // A matrix that swaps the coordinates of a point. // clang-format off @@ -407,13 +424,15 @@ PathBuilder& PathBuilder::AddRoundSuperellipse(RoundSuperellipse rse) { RoundSuperellipseBuilder builder( [this](const Point& a, const Point& b, const Point& c, const Point& d) { AddCubicComponent(a, b, c, d); - }); + }, + [this](const Point& a) { LineTo(a); }); auto param = RoundSuperellipseParam::MakeBoundsRadii(rse.GetBounds(), rse.GetRadii()); - Point start = param.top_right.offset + - param.top_right.signed_scale * - (param.top_right.top.offset + param.top_right.top.edge_mid); + Point start = + param.top_right.offset + + param.top_right.signed_scale * + (param.top_right.top.offset + Point(0, param.top_right.top.se_a)); MoveTo(start); if (param.all_corners_same) { diff --git a/engine/src/flutter/impeller/geometry/round_superellipse_param.cc b/engine/src/flutter/impeller/geometry/round_superellipse_param.cc index 243040813c14a..ce82ead8675aa 100644 --- a/engine/src/flutter/impeller/geometry/round_superellipse_param.cc +++ b/engine/src/flutter/impeller/geometry/round_superellipse_param.cc @@ -34,45 +34,68 @@ inline Point Flip(Point a) { // The columns represent the following variabls respectively: // // * n -// * sin(thetaJ) +// * k_xJ, which is defined as 1 / (1 - xJ / a) // // For definition of the variables, see ComputeOctant. constexpr Scalar kPrecomputedVariables[][2] = { - /*ratio=2.00*/ {2.00000000, 0.117205737}, - /*ratio=2.02*/ {2.03999083, 0.117205737}, - /*ratio=2.04*/ {2.07976152, 0.119418745}, - /*ratio=2.06*/ {2.11195967, 0.136274515}, - /*ratio=2.08*/ {2.14721808, 0.141289310}, - /*ratio=2.10*/ {2.18349805, 0.143410679}, - /*ratio=2.12*/ {2.21858213, 0.146668334}, - /*ratio=2.14*/ {2.24861661, 0.154985392}, - /*ratio=2.16*/ {2.28146030, 0.158932848}, - /*ratio=2.18*/ {2.30842385, 0.168182439}, - /*ratio=2.20*/ {2.33888662, 0.172911853}, - /*ratio=2.22*/ {2.36937163, 0.177039959}, - /*ratio=2.24*/ {2.40317673, 0.177839181}, - /*ratio=2.26*/ {2.42840031, 0.185615110}, - /*ratio=2.28*/ {2.45838300, 0.188905374}, - /*ratio=2.30*/ {2.48660575, 0.193273145}}; -constexpr Scalar kRatioStepInverse = 50; // = 1 / 0.02 + /*ratio=2.00*/ {2.00000000, 1.13276676}, + /*ratio=2.10*/ {2.18349805, 1.20311921}, + /*ratio=2.20*/ {2.33888662, 1.28698796}, + /*ratio=2.30*/ {2.48660575, 1.36351941}, + /*ratio=2.40*/ {2.62226596, 1.44717976}, + /*ratio=2.50*/ {2.75148990, 1.53385819}, + /*ratio=3.00*/ {3.36298265, 1.98288283}, + /*ratio=3.50*/ {4.08649929, 2.23811846}, + /*ratio=4.00*/ {4.85481134, 2.47563463}, + /*ratio=4.50*/ {5.62945551, 2.72948597}, + /*ratio=5.00*/ {6.43023796, 2.98020421}}; + +constexpr Scalar kMinRatio = 2.00; + +// The curve is split into 3 parts: +// * The first part uses a denser look up table. +// * The second part uses a sparser look up table. +// * The third part uses a straight line. +constexpr Scalar kFirstStepInverse = 10; // = 1 / 0.10 +constexpr Scalar kFirstMaxRatio = 2.50; +constexpr Scalar kFirstNumRecords = 6; + +constexpr Scalar kSecondStepInverse = 2; // = 1 / 0.50 +constexpr Scalar kSecondMaxRatio = 5.00; + +constexpr Scalar kThirdNSlope = 1.559599389; +constexpr Scalar kThirdKxjSlope = 0.522807185; constexpr size_t kNumRecords = sizeof(kPrecomputedVariables) / sizeof(kPrecomputedVariables[0]); -constexpr Scalar kMinRatio = 2.00f; -constexpr Scalar kMaxRatio = kMinRatio + (kNumRecords - 1) / kRatioStepInverse; -// Linear interpolation for `kPrecomputedVariables`. -// -// The `column` is a 0-based index that decides the target variable. -Scalar LerpPrecomputedVariable(size_t column, Scalar ratio) { - Scalar steps = std::clamp((ratio - kMinRatio) * kRatioStepInverse, 0, - kNumRecords - 1); +// Compute the `n` and `xJ / a` for the given ratio. +std::array ComputeNAndXj(Scalar ratio) { + if (ratio > kSecondMaxRatio) { + Scalar n = kThirdNSlope * (ratio - kSecondMaxRatio) + + kPrecomputedVariables[kNumRecords - 1][0]; + Scalar k_xJ = kThirdKxjSlope * (ratio - kSecondMaxRatio) + + kPrecomputedVariables[kNumRecords - 1][1]; + return {n, 1 - 1 / k_xJ}; + } + ratio = std::clamp(ratio, kMinRatio, kSecondMaxRatio); + Scalar steps; + if (ratio < kFirstMaxRatio) { + steps = (ratio - kMinRatio) * kFirstStepInverse; + } else { + steps = + (ratio - kFirstMaxRatio) * kSecondStepInverse + kFirstNumRecords - 1; + } + size_t left = std::clamp(static_cast(std::floor(steps)), 0, kNumRecords - 2); Scalar frac = steps - left; - return (1 - frac) * kPrecomputedVariables[left][column] + - frac * kPrecomputedVariables[left + 1][column]; + Scalar n = (1 - frac) * kPrecomputedVariables[left][0] + + frac * kPrecomputedVariables[left + 1][0]; + Scalar k_xJ = (1 - frac) * kPrecomputedVariables[left][1] + + frac * kPrecomputedVariables[left + 1][1]; + return {n, 1 - 1 / k_xJ}; } // Find the center of the circle that passes the given two points and have the @@ -101,53 +124,51 @@ Point FindCircleCenter(Point a, Point b, Scalar r) { // Compute parameters for a square-like rounded superellipse with a symmetrical // radius. RoundSuperellipseParam::Octant ComputeOctant(Point center, - Scalar half_size, + Scalar a, Scalar radius) { /* The following figure shows the first quadrant of a square-like rounded - * superellipse. The target arc consists of the "stretch" (AB), a - * superellipsoid arc (BJ), and a circular arc (JM). + * superellipse. * - * straight superelipse - * ↓ ↓ - * A B J circular arc - * ---------...._ ↙ - * | | / `⟍ M - * | | / ⟋ ⟍ - * | | / ⟋ \ - * | | / ⟋ | - * | | ᜱD | - * | | / | - * ↑ +----+ S | - * s | | | - * ↓ +----+---------------| A' + * superelipse + * A ↓ circular arc + * ---------...._J ↙ + * | / `⟍ M (where x=y) + * | / ⟋ ⟍ + * | / ⟋ \ + * | / ⟋ | + * | ᜱD | + * | ⟋ | + * | ⟋ | + * |⟋ | + * +--------------------| A' * O - * ← s → - * ←---- half_size -----→ + * ←-------- a ---------→ */ - Scalar ratio = - radius == 0 ? kMaxRatio : std::min(half_size * 2 / radius, kMaxRatio); - Scalar a = ratio * radius / 2; - Scalar s = half_size - a; - Scalar g = RoundSuperellipseParam::kGapFactor * radius; + if (radius <= 0) { + return RoundSuperellipseParam::Octant{ + .offset = center, - Scalar n = LerpPrecomputedVariable(0, ratio); - Scalar sin_thetaJ = radius == 0 ? 0 : LerpPrecomputedVariable(1, ratio); + .se_a = a, + .se_n = 0, + }; + } + + Scalar ratio = a * 2 / radius; + Scalar g = RoundSuperellipseParam::kGapFactor * radius; - Scalar sin_thetaJ_sq = sin_thetaJ * sin_thetaJ; - Scalar cos_thetaJ_sq = 1 - sin_thetaJ_sq; - Scalar tan_thetaJ_sq = sin_thetaJ_sq / cos_thetaJ_sq; + auto precomputed_vars = ComputeNAndXj(ratio); + Scalar n = precomputed_vars[0]; + Scalar xJ = precomputed_vars[1] * a; + Scalar yJ = pow(1 - pow(precomputed_vars[1], n), 1 / n) * a; + Scalar max_theta = asinf(pow(precomputed_vars[1], n / 2)); - Scalar xJ = a * pow(sin_thetaJ_sq, 1 / n); - Scalar yJ = a * pow(cos_thetaJ_sq, 1 / n); - Scalar tan_phiJ = pow(tan_thetaJ_sq, (n - 1) / n); + Scalar tan_phiJ = pow(xJ / yJ, n - 1); Scalar d = (xJ - tan_phiJ * yJ) / (1 - tan_phiJ); Scalar R = (a - d - g) * sqrt(2); - Point pointA{0, half_size}; - Point pointM{half_size - g, half_size - g}; - Point pointS{s, s}; - Point pointJ = Point{xJ, yJ} + pointS; + Point pointM{a - g, a - g}; + Point pointJ = Point{xJ, yJ}; Point circle_center = radius == 0 ? pointM : FindCircleCenter(pointJ, pointM, R); Radians circle_max_angle = @@ -157,12 +178,9 @@ RoundSuperellipseParam::Octant ComputeOctant(Point center, return RoundSuperellipseParam::Octant{ .offset = center, - .edge_mid = pointA, - - .se_center = pointS, .se_a = a, .se_n = n, - .se_max_theta = asin(sin_thetaJ), + .se_max_theta = max_theta, .circle_start = pointJ, .circle_center = circle_center, @@ -225,13 +243,9 @@ bool OctantContains(const RoundSuperellipseParam::Octant& param, if (p.x < 0 || p.y < 0 || p.y < p.x) { return true; } - // Check if the point is within the stretch segment. - if (p.x <= param.se_center.x) { - return p.y <= param.edge_mid.y; - } // Check if the point is within the superellipsoid segment. if (p.x <= param.circle_start.x) { - Point p_se = (p - param.se_center) / param.se_a; + Point p_se = p / param.se_a; return powf(p_se.x, param.se_n) + powf(p_se.y, param.se_n) <= 1; } Scalar circle_radius = @@ -265,6 +279,15 @@ bool CornerContains(const RoundSuperellipseParam::Quadrant& param, } else { norm_point = norm_point.Abs(); } + if (param.top.se_n < 2 || param.right.se_n < 2) { + // A rectangular corner. The top and left sides contain the borders + // while the bottom and right sides don't (see `Rect.contains`). + Scalar x_delta = param.right.offset.x + param.right.se_a - norm_point.x; + Scalar y_delta = param.top.offset.y + param.top.se_a - norm_point.y; + bool x_within = x_delta > 0 || (x_delta == 0 && param.signed_scale.x < 0); + bool y_within = y_delta > 0 || (y_delta == 0 && param.signed_scale.y < 0); + return x_within && y_within; + } return OctantContains(param.top, norm_point - param.top.offset) && OctantContains(param.right, Flip(norm_point - param.right.offset)); } @@ -272,37 +295,37 @@ bool CornerContains(const RoundSuperellipseParam::Quadrant& param, } // namespace RoundSuperellipseParam RoundSuperellipseParam::MakeBoundsRadii( - const Rect& bounds_, - const RoundingRadii& radii_) { - if (radii_.AreAllCornersSame()) { + const Rect& bounds, + const RoundingRadii& radii) { + if (radii.AreAllCornersSame() && !radii.top_left.IsEmpty()) { + // Having four empty corners indicate a rectangle, which needs special + // treatment on border containment and therefore is not `all_corners_same`. return RoundSuperellipseParam{ - .top_right = ComputeQuadrant(bounds_.GetCenter(), bounds_.GetRightTop(), - radii_.top_right), + .top_right = ComputeQuadrant(bounds.GetCenter(), bounds.GetRightTop(), + radii.top_right), .all_corners_same = true, }; } - Scalar top_split = Split(bounds_.GetLeft(), bounds_.GetRight(), - radii_.top_left.width, radii_.top_right.width); - Scalar right_split = - Split(bounds_.GetTop(), bounds_.GetBottom(), radii_.top_right.height, - radii_.bottom_right.height); + Scalar top_split = Split(bounds.GetLeft(), bounds.GetRight(), + radii.top_left.width, radii.top_right.width); + Scalar right_split = Split(bounds.GetTop(), bounds.GetBottom(), + radii.top_right.height, radii.bottom_right.height); Scalar bottom_split = - Split(bounds_.GetLeft(), bounds_.GetRight(), radii_.bottom_left.width, - radii_.bottom_right.width); - Scalar left_split = Split(bounds_.GetTop(), bounds_.GetBottom(), - radii_.top_left.height, radii_.bottom_left.height); + Split(bounds.GetLeft(), bounds.GetRight(), radii.bottom_left.width, + radii.bottom_right.width); + Scalar left_split = Split(bounds.GetTop(), bounds.GetBottom(), + radii.top_left.height, radii.bottom_left.height); return RoundSuperellipseParam{ .top_right = ComputeQuadrant(Point{top_split, right_split}, - bounds_.GetRightTop(), radii_.top_right), + bounds.GetRightTop(), radii.top_right), .bottom_right = ComputeQuadrant(Point{bottom_split, right_split}, - bounds_.GetRightBottom(), radii_.bottom_right), - .bottom_left = - ComputeQuadrant(Point{bottom_split, left_split}, - bounds_.GetLeftBottom(), radii_.bottom_left), + bounds.GetRightBottom(), radii.bottom_right), + .bottom_left = ComputeQuadrant(Point{bottom_split, left_split}, + bounds.GetLeftBottom(), radii.bottom_left), .top_left = ComputeQuadrant(Point{top_split, left_split}, - bounds_.GetLeftTop(), radii_.top_left), + bounds.GetLeftTop(), radii.top_left), .all_corners_same = false, }; } diff --git a/engine/src/flutter/impeller/geometry/round_superellipse_param.h b/engine/src/flutter/impeller/geometry/round_superellipse_param.h index 6318255588b51..dfd696fda6006 100644 --- a/engine/src/flutter/impeller/geometry/round_superellipse_param.h +++ b/engine/src/flutter/impeller/geometry/round_superellipse_param.h @@ -19,6 +19,9 @@ struct RoundSuperellipseParam { // // This structure is used to define an octant of an arbitrary rounded // superellipse. + // + // A `se_n` of 0 means that the radius is 0, and this octant is a square + // of size `se_a` at `offset` and all other fields are ignored. struct Octant { // The offset of the square-like rounded superellipse's center from the // origin. @@ -26,18 +29,11 @@ struct RoundSuperellipseParam { // All other coordinates in this structure are relative to this point. Point offset; - // The coordinate of the midpoint of the top edge, relative to the `offset` - // point. - // - // This is the starting point of the octant curve. - Point edge_mid; - - // The coordinate of the superellipse's center, relative to the `offset` - // point. - Point se_center; // The semi-axis length of the superellipse. Scalar se_a; // The degree of the superellipse. + // + // If this value is 0, then this octant is a square of size `se_a`. Scalar se_n; // The range of the parameter "theta" used to define the superellipse curve. // diff --git a/engine/src/flutter/impeller/geometry/round_superellipse_unittests.cc b/engine/src/flutter/impeller/geometry/round_superellipse_unittests.cc index e8cc34a8d39bd..2c87d5e4f740f 100644 --- a/engine/src/flutter/impeller/geometry/round_superellipse_unittests.cc +++ b/engine/src/flutter/impeller/geometry/round_superellipse_unittests.cc @@ -592,13 +592,13 @@ TEST(RoundSuperellipseTest, UniformSquareContains) { CHECK_POINT_WITH_OFFSET(rr, (p) * Point(-1, 1), Point(-0.02, 0.02)); \ CHECK_POINT_WITH_OFFSET(rr, (p) * Point(-1, -1), Point(-0.02, -0.02)); - CHECK_POINT_AND_MIRRORS(Point(0, 49.995)); // Top - CHECK_POINT_AND_MIRRORS(Point(44.245, 49.995)); // Top stretch end - CHECK_POINT_AND_MIRRORS(Point(45.72, 49.92)); // Top joint - CHECK_POINT_AND_MIRRORS(Point(48.53, 48.53)); // Circular arc mid - CHECK_POINT_AND_MIRRORS(Point(49.92, 45.72)); // Right joint - CHECK_POINT_AND_MIRRORS(Point(49.995, 44.245)); // Right stretch end - CHECK_POINT_AND_MIRRORS(Point(49.995, 0)); // Right + CHECK_POINT_AND_MIRRORS(Point(0, 49.995)); // Top + CHECK_POINT_AND_MIRRORS(Point(44.245, 49.95)); // Top curve start + CHECK_POINT_AND_MIRRORS(Point(45.72, 49.87)); // Top joint + CHECK_POINT_AND_MIRRORS(Point(48.53, 48.53)); // Circular arc mid + CHECK_POINT_AND_MIRRORS(Point(49.87, 45.72)); // Right joint + CHECK_POINT_AND_MIRRORS(Point(49.95, 44.245)); // Right curve start + CHECK_POINT_AND_MIRRORS(Point(49.995, 0)); // Right #undef CHECK_POINT_AND_MIRRORS } @@ -614,11 +614,11 @@ TEST(RoundSuperellipseTest, UniformEllipticalContains) { CHECK_POINT_WITH_OFFSET(rr, (p) * Point(-1, -1), Point(-0.02, -0.02)); CHECK_POINT_AND_MIRRORS(Point(0, 49.995)); // Top - CHECK_POINT_AND_MIRRORS(Point(44.245, 49.995)); // Top stretch end - CHECK_POINT_AND_MIRRORS(Point(45.72, 49.84)); // Top joint + CHECK_POINT_AND_MIRRORS(Point(44.245, 49.911)); // Top curve start + CHECK_POINT_AND_MIRRORS(Point(45.72, 49.75)); // Top joint CHECK_POINT_AND_MIRRORS(Point(48.51, 47.07)); // Circular arc mid - CHECK_POINT_AND_MIRRORS(Point(49.92, 41.44)); // Right joint - CHECK_POINT_AND_MIRRORS(Point(49.995, 38.49)); // Right stretch end + CHECK_POINT_AND_MIRRORS(Point(49.87, 41.44)); // Right joint + CHECK_POINT_AND_MIRRORS(Point(49.95, 38.49)); // Right curve start CHECK_POINT_AND_MIRRORS(Point(49.995, 0)); // Right #undef CHECK_POINT_AND_MIRRORS } @@ -645,9 +645,9 @@ TEST(RoundSuperellipseTest, UniformRectangularContains) { CHECK_POINT_AND_MIRRORS(Point(34.99, 98.06)); CHECK_POINT_AND_MIRRORS(Point(39.99, 94.73)); CHECK_POINT_AND_MIRRORS(Point(44.13, 89.99)); - CHECK_POINT_AND_MIRRORS(Point(48.60, 79.99)); - CHECK_POINT_AND_MIRRORS(Point(49.93, 69.99)); - CHECK_POINT_AND_MIRRORS(Point(49.99, 59.99)); + CHECK_POINT_AND_MIRRORS(Point(48.46, 79.99)); + CHECK_POINT_AND_MIRRORS(Point(49.70, 69.99)); + CHECK_POINT_AND_MIRRORS(Point(49.97, 59.99)); CHECK_POINT_AND_MIRRORS(Point(49.99, 49.99)); // Right mid edge #undef CHECK_POINT_AND_MIRRORS diff --git a/packages/flutter/lib/src/cupertino/dialog.dart b/packages/flutter/lib/src/cupertino/dialog.dart index fc6d146e9cfaa..76386b11df731 100644 --- a/packages/flutter/lib/src/cupertino/dialog.dart +++ b/packages/flutter/lib/src/cupertino/dialog.dart @@ -570,7 +570,7 @@ class CupertinoPopupSurface extends StatelessWidget { static const double defaultBlurSigma = 30.0; /// The default corner radius of a [CupertinoPopupSurface]. - static const BorderRadius _clipper = BorderRadius.all(Radius.circular(12)); + static const BorderRadius _clipper = BorderRadius.all(Radius.circular(13)); // The [ColorFilter] matrix used to saturate widgets underlying a // [CupertinoPopupSurface] when the ambient [CupertinoThemeData.brightness] is From 6d6d7914f99fcee9a6ae68829bd27997c8bff86b Mon Sep 17 00:00:00 2001 From: Sarbagya Dhaubanjar Date: Sat, 8 Mar 2025 08:26:17 +0545 Subject: [PATCH 11/21] Added calendar delegate to support custom calendar systems (#161874) Added `CalendarDelegate` class that supports plugging in custom calendar logics other than Gregorian Calendar System. Here is an example implementation for Nepali(Bikram Sambat) Calendar System: https://github.com/sarbagyastha/nepali_date_picker/blob/m3/lib/src/nepali_calendar_delegate.dart Demo using the `NepaliDatePickerDelegate`: https://date.sarbagyastha.com.np/ Fixes https://github.com/flutter/flutter/issues/77531, https://github.com/flutter/flutter/issues/161873 ## Pre-launch Checklist - [X] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. --------- Co-authored-by: Tong Mu --- .../custom_calendar_date_picker.0.dart | 148 ++++++++++ .../custom_calendar_date_picker.0_test.dart | 45 +++ .../src/material/calendar_date_picker.dart | 164 +++++++---- packages/flutter/lib/src/material/date.dart | 257 +++++++++++++++++- .../flutter/lib/src/material/date_picker.dart | 175 ++++++++---- .../input_date_picker_form_field.dart | 16 +- .../material/calendar_date_picker_test.dart | 75 +++++ .../test/material/date_picker_test.dart | 94 +++++++ .../test/material/date_range_picker_test.dart | 109 ++++++++ .../input_date_picker_form_field_test.dart | 120 ++++++++ 10 files changed, 1095 insertions(+), 108 deletions(-) create mode 100644 examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart create mode 100644 examples/api/test/material/date_picker/custom_calendar_date_picker.0_test.dart diff --git a/examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart b/examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart new file mode 100644 index 0000000000000..1775062fecb3e --- /dev/null +++ b/examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart @@ -0,0 +1,148 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; + +/// Flutter code sample demonstrating how to use a custom [CalendarDelegate] +/// with [CalendarDatePicker] to implement a hypothetical calendar system +/// where even-numbered months have 21 days, odd-numbered months have 28 days, +/// and every month starts on a Monday. + +void main() => runApp(const CalendarDatePickerApp()); + +class CalendarDatePickerApp extends StatelessWidget { + const CalendarDatePickerApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp(home: CalendarDatePickerExample()); + } +} + +class CalendarDatePickerExample extends StatefulWidget { + const CalendarDatePickerExample({super.key}); + + @override + State createState() => _CalendarDatePickerExampleState(); +} + +class _CalendarDatePickerExampleState extends State { + DateTime? selectedDate; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Custom Calendar')), + body: Column( + spacing: 16, + children: [ + CalendarDatePicker( + initialDate: DateTime(2025, 2, 8), + firstDate: DateTime(2025), + lastDate: DateTime(2026), + onDateChanged: (DateTime pickedDate) { + setState(() { + selectedDate = pickedDate; + }); + }, + calendarDelegate: const CustomCalendarDelegate(), + ), + const Divider(height: 1), + Text( + selectedDate != null + ? '${selectedDate!.day}/${selectedDate!.month}/${selectedDate!.year}' + : 'No date selected', + ), + ], + ), + ); + } +} + +/// A custom calendar system where even-numbered months have 21 days, +/// odd-numbered months have 28 days, and every month starts on a Monday. +/// +/// This hypothetical calendar follows a fixed structure: +/// - **Even-numbered months (2, 4, 6, etc.)** always have **21 days**. +/// - **Odd-numbered months (1, 3, 5, etc.)** always have **28 days**. +/// - **The first day of every month is always a Monday**, ensuring a consistent weekly alignment. +class CustomCalendarDelegate extends CalendarDelegate { + const CustomCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } + + // ------------------------------------------------------------------------ + // All the implementations below are based on the Gregorian calendar system. + + @override + DateTime now() => DateTime.now(); + + @override + DateTime dateOnly(DateTime date) => DateUtils.dateOnly(date); + + @override + int monthDelta(DateTime startDate, DateTime endDate) => DateUtils.monthDelta(startDate, endDate); + + @override + DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { + return DateUtils.addMonthsToMonthDate(monthDate, monthsToAdd); + } + + @override + DateTime addDaysToDate(DateTime date, int days) => DateUtils.addDaysToDate(date, days); + + @override + DateTime getMonth(int year, int month) => DateTime(year, month); + + @override + DateTime getDay(int year, int month, int day) => DateTime(year, month, day); + + @override + String formatMonthYear(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMonthYear(date); + } + + @override + String formatMediumDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMediumDate(date); + } + + @override + String formatShortMonthDay(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortMonthDay(date); + } + + @override + String formatShortDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortDate(date); + } + + @override + String formatFullDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatFullDate(date); + } + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatCompactDate(date); + } + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + return localizations.parseCompactDate(inputString); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return localizations.dateHelpText; + } +} diff --git a/examples/api/test/material/date_picker/custom_calendar_date_picker.0_test.dart b/examples/api/test/material/date_picker/custom_calendar_date_picker.0_test.dart new file mode 100644 index 0000000000000..b34c41fca3070 --- /dev/null +++ b/examples/api/test/material/date_picker/custom_calendar_date_picker.0_test.dart @@ -0,0 +1,45 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/material.dart'; +import 'package:flutter_api_samples/material/date_picker/custom_calendar_date_picker.0.dart' + as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + Text getLastDayText(WidgetTester tester) { + final Finder dayFinder = find.descendant(of: find.byType(Ink), matching: find.byType(Text)); + return tester.widget(dayFinder.last); + } + + testWidgets('Days are based on the calendar delegate', (WidgetTester tester) async { + await tester.pumpWidget(const example.CalendarDatePickerApp()); + + final Finder nextMonthButton = find.byIcon(Icons.chevron_right); + + Text lastDayText = getLastDayText(tester); + expect(find.text('February 2025'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(find.text('March 2025'), findsOneWidget); + expect(lastDayText.data, equals('28')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(find.text('April 2025'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(tester); + expect(lastDayText.data, equals('28')); + }); +} diff --git a/packages/flutter/lib/src/material/calendar_date_picker.dart b/packages/flutter/lib/src/material/calendar_date_picker.dart index 3dba92cda64d8..0fcc60bd59ef5 100644 --- a/packages/flutter/lib/src/material/calendar_date_picker.dart +++ b/packages/flutter/lib/src/material/calendar_date_picker.dart @@ -107,6 +107,13 @@ class CalendarDatePicker extends StatefulWidget { /// /// If [selectableDayPredicate] and [initialDate] are both non-null, /// [selectableDayPredicate] must return `true` for the [initialDate]. + /// + /// {@template flutter.material.calendar_date_picker.calendarDelegate} + /// The [calendarDelegate] controls date interpretation, formatting, and + /// navigation within the picker. By providing a custom implementation, + /// you can support alternative calendar systems such as Nepali, Hijri, + /// Buddhist, and more. Defaults to [GregorianCalendarDelegate]. + /// {@endtemplate} CalendarDatePicker({ super.key, required DateTime? initialDate, @@ -117,10 +124,11 @@ class CalendarDatePicker extends StatefulWidget { this.onDisplayedMonthChanged, this.initialCalendarMode = DatePickerMode.day, this.selectableDayPredicate, - }) : initialDate = initialDate == null ? null : DateUtils.dateOnly(initialDate), - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate), - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()) { + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { assert( !this.lastDate.isBefore(this.firstDate), 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', @@ -175,6 +183,9 @@ class CalendarDatePicker extends StatefulWidget { /// Function to provide full control over which dates in the calendar can be selected. final SelectableDayPredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _CalendarDatePickerState(); } @@ -194,7 +205,10 @@ class _CalendarDatePickerState extends State { super.initState(); _mode = widget.initialCalendarMode; final DateTime currentDisplayedDate = widget.initialDate ?? widget.currentDate; - _currentDisplayedMonthDate = DateTime(currentDisplayedDate.year, currentDisplayedDate.month); + _currentDisplayedMonthDate = widget.calendarDelegate.getMonth( + currentDisplayedDate.year, + currentDisplayedDate.month, + ); if (widget.initialDate != null) { _selectedDate = widget.initialDate; } @@ -211,7 +225,7 @@ class _CalendarDatePickerState extends State { if (!_announcedInitialDate && widget.initialDate != null) { assert(_selectedDate != null); _announcedInitialDate = true; - final bool isToday = DateUtils.isSameDay(widget.currentDate, _selectedDate); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, _selectedDate); final String semanticLabelSuffix = isToday ? ', ${_localizations.currentDateLabel}' : ''; SemanticsService.announce( '${_localizations.formatFullDate(_selectedDate!)}$semanticLabelSuffix', @@ -239,8 +253,8 @@ class _CalendarDatePickerState extends State { _mode = mode; if (_selectedDate case final DateTime selected) { final String message = switch (mode) { - DatePickerMode.day => _localizations.formatMonthYear(selected), - DatePickerMode.year => _localizations.formatYear(selected), + DatePickerMode.day => widget.calendarDelegate.formatMonthYear(selected, _localizations), + DatePickerMode.year => widget.calendarDelegate.formatYear(selected.year, _localizations), }; SemanticsService.announce(message, _textDirection); } @@ -251,7 +265,7 @@ class _CalendarDatePickerState extends State { setState(() { if (_currentDisplayedMonthDate.year != date.year || _currentDisplayedMonthDate.month != date.month) { - _currentDisplayedMonthDate = DateTime(date.year, date.month); + _currentDisplayedMonthDate = widget.calendarDelegate.getMonth(date.year, date.month); widget.onDisplayedMonthChanged?.call(_currentDisplayedMonthDate); } }); @@ -260,9 +274,9 @@ class _CalendarDatePickerState extends State { void _handleYearChanged(DateTime value) { _vibrate(); - final int daysInMonth = DateUtils.getDaysInMonth(value.year, value.month); + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(value.year, value.month); final int preferredDay = math.min(_selectedDate?.day ?? 1, daysInMonth); - value = value.copyWith(day: preferredDay); + value = widget.calendarDelegate.getDay(value.year, value.month, preferredDay); if (value.isBefore(widget.firstDate)) { value = widget.firstDate; @@ -290,10 +304,10 @@ class _CalendarDatePickerState extends State { case TargetPlatform.linux: case TargetPlatform.macOS: case TargetPlatform.windows: - final bool isToday = DateUtils.isSameDay(widget.currentDate, _selectedDate); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, _selectedDate); final String semanticLabelSuffix = isToday ? ', ${_localizations.currentDateLabel}' : ''; SemanticsService.announce( - '${_localizations.selectedDateLabel} ${_localizations.formatFullDate(_selectedDate!)}$semanticLabelSuffix', + '${_localizations.selectedDateLabel} ${widget.calendarDelegate.formatFullDate(_selectedDate!, _localizations)}$semanticLabelSuffix', _textDirection, ); case TargetPlatform.android: @@ -313,6 +327,7 @@ class _CalendarDatePickerState extends State { case DatePickerMode.day: return _MonthPicker( key: _monthPickerKey, + calendarDelegate: widget.calendarDelegate, initialMonth: _currentDisplayedMonthDate, currentDate: widget.currentDate, firstDate: widget.firstDate, @@ -327,6 +342,7 @@ class _CalendarDatePickerState extends State { padding: const EdgeInsets.only(top: _subHeaderHeight), child: YearPicker( key: _yearPickerKey, + calendarDelegate: widget.calendarDelegate, currentDate: widget.currentDate, firstDate: widget.firstDate, lastDate: widget.lastDate, @@ -363,7 +379,10 @@ class _CalendarDatePickerState extends State { maxScaleFactor: _kModeToggleButtonMaxScaleFactor, child: _DatePickerModeToggleButton( mode: _mode, - title: _localizations.formatMonthYear(_currentDisplayedMonthDate), + title: widget.calendarDelegate.formatMonthYear( + _currentDisplayedMonthDate, + _localizations, + ), onTitlePressed: () => _handleModeChanged(switch (_mode) { DatePickerMode.day => DatePickerMode.year, @@ -499,6 +518,7 @@ class _MonthPicker extends StatefulWidget { required this.selectedDate, required this.onChanged, required this.onDisplayedMonthChanged, + required this.calendarDelegate, this.selectableDayPredicate, }) : assert(!firstDate.isAfter(lastDate)), assert(selectedDate == null || !selectedDate.isBefore(firstDate)), @@ -541,6 +561,9 @@ class _MonthPicker extends StatefulWidget { /// Optional user supplied predicate function to customize selectable days. final SelectableDayPredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override _MonthPickerState createState() => _MonthPickerState(); } @@ -561,7 +584,7 @@ class _MonthPickerState extends State<_MonthPicker> { super.initState(); _currentMonth = widget.initialMonth; _pageController = PageController( - initialPage: DateUtils.monthDelta(widget.firstDate, _currentMonth), + initialPage: widget.calendarDelegate.monthDelta(widget.firstDate, _currentMonth), ); _shortcutMap = const { SingleActivator(LogicalKeyboardKey.arrowLeft): DirectionalFocusIntent( @@ -606,17 +629,24 @@ class _MonthPickerState extends State<_MonthPicker> { void _handleMonthPageChanged(int monthPage) { setState(() { - final DateTime monthDate = DateUtils.addMonthsToMonthDate(widget.firstDate, monthPage); - if (!DateUtils.isSameMonth(_currentMonth, monthDate)) { - _currentMonth = DateTime(monthDate.year, monthDate.month); + final DateTime monthDate = widget.calendarDelegate.addMonthsToMonthDate( + widget.firstDate, + monthPage, + ); + if (!widget.calendarDelegate.isSameMonth(_currentMonth, monthDate)) { + _currentMonth = widget.calendarDelegate.getMonth(monthDate.year, monthDate.month); widget.onDisplayedMonthChanged(_currentMonth); - if (_focusedDay != null && !DateUtils.isSameMonth(_focusedDay, _currentMonth)) { + if (_focusedDay != null && + !widget.calendarDelegate.isSameMonth(_focusedDay, _currentMonth)) { // We have navigated to a new month with the grid focused, but the // focused day is not in this month. Choose a new one trying to keep // the same day of the month. _focusedDay = _focusableDayForMonth(_currentMonth, _focusedDay!.day); } - SemanticsService.announce(_localizations.formatMonthYear(_currentMonth), _textDirection); + SemanticsService.announce( + widget.calendarDelegate.formatMonthYear(_currentMonth, _localizations), + _textDirection, + ); } }); } @@ -627,11 +657,15 @@ class _MonthPickerState extends State<_MonthPicker> { /// otherwise the first selectable day in the month will be returned. If /// no dates are selectable in the month, then it will return null. DateTime? _focusableDayForMonth(DateTime month, int preferredDay) { - final int daysInMonth = DateUtils.getDaysInMonth(month.year, month.month); + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(month.year, month.month); // Can we use the preferred day in this month? if (preferredDay <= daysInMonth) { - final DateTime newFocus = DateTime(month.year, month.month, preferredDay); + final DateTime newFocus = widget.calendarDelegate.getDay( + month.year, + month.month, + preferredDay, + ); if (_isSelectable(newFocus)) { return newFocus; } @@ -639,7 +673,7 @@ class _MonthPickerState extends State<_MonthPicker> { // Start at the 1st and take the first selectable date. for (int day = 1; day <= daysInMonth; day++) { - final DateTime newFocus = DateTime(month.year, month.month, day); + final DateTime newFocus = widget.calendarDelegate.getDay(month.year, month.month, day); if (_isSelectable(newFocus)) { return newFocus; } @@ -663,7 +697,7 @@ class _MonthPickerState extends State<_MonthPicker> { /// Navigate to the given month. void _showMonth(DateTime month, {bool jump = false}) { - final int monthPage = DateUtils.monthDelta(widget.firstDate, month); + final int monthPage = widget.calendarDelegate.monthDelta(widget.firstDate, month); if (jump) { _pageController.jumpToPage(monthPage); } else { @@ -673,21 +707,25 @@ class _MonthPickerState extends State<_MonthPicker> { /// True if the earliest allowable month is displayed. bool get _isDisplayingFirstMonth { - return !_currentMonth.isAfter(DateTime(widget.firstDate.year, widget.firstDate.month)); + return !_currentMonth.isAfter( + widget.calendarDelegate.getMonth(widget.firstDate.year, widget.firstDate.month), + ); } /// True if the latest allowable month is displayed. bool get _isDisplayingLastMonth { - return !_currentMonth.isBefore(DateTime(widget.lastDate.year, widget.lastDate.month)); + return !_currentMonth.isBefore( + widget.calendarDelegate.getMonth(widget.lastDate.year, widget.lastDate.month), + ); } /// Handler for when the overall day grid obtains or loses focus. void _handleGridFocusChange(bool focused) { setState(() { if (focused && _focusedDay == null) { - if (DateUtils.isSameMonth(widget.selectedDate, _currentMonth)) { + if (widget.calendarDelegate.isSameMonth(widget.selectedDate, _currentMonth)) { _focusedDay = widget.selectedDate; - } else if (DateUtils.isSameMonth(widget.currentDate, _currentMonth)) { + } else if (widget.calendarDelegate.isSameMonth(widget.currentDate, _currentMonth)) { _focusedDay = _focusableDayForMonth(_currentMonth, widget.currentDate.day); } else { _focusedDay = _focusableDayForMonth(_currentMonth, 1); @@ -723,7 +761,7 @@ class _MonthPickerState extends State<_MonthPicker> { final DateTime? nextDate = _nextDateInDirection(_focusedDay!, intent.direction); if (nextDate != null) { _focusedDay = nextDate; - if (!DateUtils.isSameMonth(_focusedDay, _currentMonth)) { + if (!widget.calendarDelegate.isSameMonth(_focusedDay, _currentMonth)) { _showMonth(_focusedDay!); } } @@ -751,7 +789,7 @@ class _MonthPickerState extends State<_MonthPicker> { DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { final TextDirection textDirection = Directionality.of(context); - DateTime nextDate = DateUtils.addDaysToDate( + DateTime nextDate = widget.calendarDelegate.addDaysToDate( date, _dayDirectionOffset(direction, textDirection), ); @@ -759,7 +797,10 @@ class _MonthPickerState extends State<_MonthPicker> { if (_isSelectable(nextDate)) { return nextDate; } - nextDate = DateUtils.addDaysToDate(nextDate, _dayDirectionOffset(direction, textDirection)); + nextDate = widget.calendarDelegate.addDaysToDate( + nextDate, + _dayDirectionOffset(direction, textDirection), + ); } return null; } @@ -769,9 +810,10 @@ class _MonthPickerState extends State<_MonthPicker> { } Widget _buildItems(BuildContext context, int index) { - final DateTime month = DateUtils.addMonthsToMonthDate(widget.firstDate, index); + final DateTime month = widget.calendarDelegate.addMonthsToMonthDate(widget.firstDate, index); return _DayPicker( key: ValueKey(month), + calendarDelegate: widget.calendarDelegate, selectedDate: widget.selectedDate, currentDate: widget.currentDate, onChanged: _handleDateSelected, @@ -821,12 +863,14 @@ class _MonthPickerState extends State<_MonthPicker> { focusNode: _dayGridFocus, onFocusChange: _handleGridFocusChange, child: _FocusedDate( + calendarDelegate: widget.calendarDelegate, date: _dayGridFocus.hasFocus ? _focusedDay : null, child: PageView.builder( key: _pageViewKey, controller: _pageController, itemBuilder: _buildItems, - itemCount: DateUtils.monthDelta(widget.firstDate, widget.lastDate) + 1, + itemCount: + widget.calendarDelegate.monthDelta(widget.firstDate, widget.lastDate) + 1, onPageChanged: _handleMonthPageChanged, ), ), @@ -843,13 +887,14 @@ class _MonthPickerState extends State<_MonthPicker> { /// This is used by the [_MonthPicker] to let its children [_DayPicker]s know /// what the currently focused date (if any) should be. class _FocusedDate extends InheritedWidget { - const _FocusedDate({required super.child, this.date}); + const _FocusedDate({required super.child, required this.calendarDelegate, this.date}); + final CalendarDelegate calendarDelegate; final DateTime? date; @override bool updateShouldNotify(_FocusedDate oldWidget) { - return !DateUtils.isSameDay(date, oldWidget.date); + return !calendarDelegate.isSameDay(date, oldWidget.date); } static DateTime? maybeOf(BuildContext context) { @@ -872,6 +917,7 @@ class _DayPicker extends StatefulWidget { required this.lastDate, required this.selectedDate, required this.onChanged, + required this.calendarDelegate, this.selectableDayPredicate, }) : assert(!firstDate.isAfter(lastDate)), assert(selectedDate == null || !selectedDate.isBefore(firstDate)), @@ -904,6 +950,9 @@ class _DayPicker extends StatefulWidget { /// Optional user supplied predicate function to customize selectable days. final SelectableDayPredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override _DayPickerState createState() => _DayPickerState(); } @@ -915,7 +964,7 @@ class _DayPickerState extends State<_DayPicker> { @override void initState() { super.initState(); - final int daysInMonth = DateUtils.getDaysInMonth( + final int daysInMonth = widget.calendarDelegate.getDaysInMonth( widget.displayedMonth.year, widget.displayedMonth.month, ); @@ -930,7 +979,8 @@ class _DayPickerState extends State<_DayPicker> { super.didChangeDependencies(); // Check to see if the focused date is in this month, if so focus it. final DateTime? focusedDate = _FocusedDate.maybeOf(context); - if (focusedDate != null && DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { + if (focusedDate != null && + widget.calendarDelegate.isSameMonth(widget.displayedMonth, focusedDate)) { _dayFocusNodes[focusedDate.day - 1].requestFocus(); } } @@ -986,8 +1036,8 @@ class _DayPickerState extends State<_DayPicker> { final int year = widget.displayedMonth.year; final int month = widget.displayedMonth.month; - final int daysInMonth = DateUtils.getDaysInMonth(year, month); - final int dayOffset = DateUtils.firstDayOffset(year, month, localizations); + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(year, month); + final int dayOffset = widget.calendarDelegate.firstDayOffset(year, month, localizations); final List dayItems = _dayHeaders(weekdayStyle, localizations); // 1-based day of month, e.g. 1-31 for January, and 1-29 for February on @@ -998,13 +1048,16 @@ class _DayPickerState extends State<_DayPicker> { if (day < 1) { dayItems.add(const SizedBox.shrink()); } else { - final DateTime dayToBuild = DateTime(year, month, day); + final DateTime dayToBuild = widget.calendarDelegate.getDay(year, month, day); final bool isDisabled = dayToBuild.isAfter(widget.lastDate) || dayToBuild.isBefore(widget.firstDate) || (widget.selectableDayPredicate != null && !widget.selectableDayPredicate!(dayToBuild)); - final bool isSelectedDay = DateUtils.isSameDay(widget.selectedDate, dayToBuild); - final bool isToday = DateUtils.isSameDay(widget.currentDate, dayToBuild); + final bool isSelectedDay = widget.calendarDelegate.isSameDay( + widget.selectedDate, + dayToBuild, + ); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, dayToBuild); dayItems.add( _Day( @@ -1015,6 +1068,7 @@ class _DayPickerState extends State<_DayPicker> { isToday: isToday, onChanged: widget.onChanged, focusNode: _dayFocusNodes[day - 1], + calendarDelegate: widget.calendarDelegate, ), ); } @@ -1046,6 +1100,7 @@ class _Day extends StatefulWidget { required this.isToday, required this.onChanged, required this.focusNode, + required this.calendarDelegate, }); final DateTime day; @@ -1054,6 +1109,7 @@ class _Day extends StatefulWidget { final bool isToday; final ValueChanged onChanged; final FocusNode focusNode; + final CalendarDelegate calendarDelegate; @override State<_Day> createState() => _DayState(); @@ -1146,7 +1202,7 @@ class _DayState extends State<_Day> { // 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', + '${localizations.formatDecimal(widget.day.day)}, ${widget.calendarDelegate.formatFullDate(widget.day, localizations)}$semanticLabelSuffix', // Set button to true to make the date selectable. button: true, selected: widget.isSelectedDay, @@ -1232,8 +1288,9 @@ class YearPicker extends StatefulWidget { required this.selectedDate, required this.onChanged, this.dragStartBehavior = DragStartBehavior.start, + this.calendarDelegate = const GregorianCalendarDelegate(), }) : assert(!firstDate.isAfter(lastDate)), - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()); + currentDate = calendarDelegate.dateOnly(currentDate ?? DateTime.now()); /// The current date. /// @@ -1257,6 +1314,9 @@ class YearPicker extends StatefulWidget { /// {@macro flutter.widgets.scrollable.dragStartBehavior} final DragStartBehavior dragStartBehavior; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _YearPickerState(); } @@ -1365,6 +1425,7 @@ class _YearPickerState extends State { final TextStyle? itemStyle = (datePickerTheme.yearStyle ?? defaults.yearStyle)?.apply( color: textColor, ); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); Widget yearItem = Center( child: Container( decoration: decoration, @@ -1374,7 +1435,7 @@ class _YearPickerState extends State { child: Semantics( selected: isSelected, button: true, - child: Text(year.toString(), style: itemStyle), + child: Text(widget.calendarDelegate.formatYear(year, localizations), style: itemStyle), ), ), ); @@ -1382,15 +1443,20 @@ class _YearPickerState extends State { if (isDisabled) { yearItem = ExcludeSemantics(child: yearItem); } else { - DateTime date = DateTime(year, widget.selectedDate?.month ?? DateTime.january); - if (date.isBefore(DateTime(widget.firstDate.year, widget.firstDate.month))) { + DateTime date = widget.calendarDelegate.getMonth( + year, + widget.selectedDate?.month ?? DateTime.january, + ); + if (date.isBefore( + widget.calendarDelegate.getMonth(widget.firstDate.year, widget.firstDate.month), + )) { // Ignore firstDate.day because we're just working in years and months here. assert(date.year == widget.firstDate.year); - date = DateTime(year, widget.firstDate.month); + date = widget.calendarDelegate.getMonth(year, widget.firstDate.month); } else if (date.isAfter(widget.lastDate)) { // No need to ignore the day here because it can only be bigger than what we care about. assert(date.year == widget.lastDate.year); - date = DateTime(year, widget.lastDate.month); + date = widget.calendarDelegate.getMonth(year, widget.lastDate.month); } _statesController.value = states; yearItem = InkWell( diff --git a/packages/flutter/lib/src/material/date.dart b/packages/flutter/lib/src/material/date.dart index 8fecc8ed91a04..4458d0cb8a536 100644 --- a/packages/flutter/lib/src/material/date.dart +++ b/packages/flutter/lib/src/material/date.dart @@ -8,38 +8,279 @@ library; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'material_localizations.dart'; +/// Controls the calendar system used in the date picker. +/// +/// A [CalendarDelegate] defines how dates are interpreted, formatted, and +/// navigated within the picker. Different calendar systems (e.g., Gregorian, +/// Nepali, Hijri, Buddhist) can be supported by providing custom implementations. +/// +/// {@tool dartpad} +/// This example demonstrates how a [CalendarDelegate] is used to implement a +/// custom calendar system in the date picker. +/// +/// ** See code in examples/api/lib/material/date_picker/custom_calendar_date_picker.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [GregorianCalendarDelegate], the default implementation for the Gregorian calendar. +/// * [CalendarDatePicker], which uses this delegate to manage calendar-specific behavior. +abstract class CalendarDelegate { + /// Creates a calendar delegate. + const CalendarDelegate(); + + /// Returns a [DateTime] representing the current date and time. + T now(); + + /// {@macro flutter.material.date.dateOnly} + T dateOnly(T date); + + /// {@macro flutter.material.date.datesOnly} + DateTimeRange datesOnly(DateTimeRange range) { + return DateTimeRange(start: dateOnly(range.start), end: dateOnly(range.end)); + } + + /// {@macro flutter.material.date.isSameDay} + bool isSameDay(T? dateA, T? dateB) { + return dateA?.year == dateB?.year && dateA?.month == dateB?.month && dateA?.day == dateB?.day; + } + + /// {@macro flutter.material.date.isSameMonth} + bool isSameMonth(T? dateA, T? dateB) { + return dateA?.year == dateB?.year && dateA?.month == dateB?.month; + } + + /// {@macro flutter.material.date.monthDelta} + int monthDelta(T startDate, T endDate); + + /// {@macro flutter.material.date.addMonthsToMonthDate} + T addMonthsToMonthDate(T monthDate, int monthsToAdd); + + /// {@macro flutter.material.date.addDaysToDate} + T addDaysToDate(T date, int days); + + /// {@macro flutter.material.date.firstDayOffset} + int firstDayOffset(int year, int month, MaterialLocalizations localizations); + + /// Returns the number of days in a month, according to the calendar system. + int getDaysInMonth(int year, int month); + + /// Returns a [DateTime] with the given [year] and [month]. + T getMonth(int year, int month); + + /// Returns a [DateTime] with the given [year], [month], and [day]. + T getDay(int year, int month, int day); + + /// Formats the month and the year of the given [date]. + /// + /// The returned string does not contain the day of the month. This appears + /// in the date picker invoked using [showDatePicker]. + String formatMonthYear(T date, MaterialLocalizations localizations); + + /// Full unabbreviated year format, e.g. 2017 rather than 17. + String formatYear(int year, MaterialLocalizations localizations) { + return localizations.formatYear(DateTime(year)); + } + + /// Formats the date using a medium-width format. + /// + /// Abbreviates month and days of week. This appears in the header of the date + /// picker invoked using [showDatePicker]. + /// + /// Examples: + /// + /// - US English: Wed, Sep 27 + /// - Russian: ср, сент. 27 + String formatMediumDate(T date, MaterialLocalizations localizations); + + /// Formats the month and day of the given [date]. + /// + /// Examples: + /// + /// - US English: Feb 21 + /// - Russian: 21 февр. + String formatShortMonthDay(T date, MaterialLocalizations localizations); + + /// Formats the date using a short-width format. + /// + /// Includes the abbreviation of the month, the day and year. + /// + /// Examples: + /// + /// - US English: Feb 21, 2019 + /// - Russian: 21 февр. 2019 г. + String formatShortDate(T date, MaterialLocalizations localizations); + + /// Formats day of week, month, day of month and year in a long-width format. + /// + /// Does not abbreviate names. Appears in spoken announcements of the date + /// picker invoked using [showDatePicker], when accessibility mode is on. + /// + /// Examples: + /// + /// - US English: Wednesday, September 27, 2017 + /// - Russian: Среда, Сентябрь 27, 2017 + String formatFullDate(T date, MaterialLocalizations localizations); + + /// Formats the date in a compact format. + /// + /// Usually just the numeric values for the for day, month and year are used. + /// + /// Examples: + /// + /// - US English: 02/21/2019 + /// - Russian: 21.02.2019 + /// + /// See also: + /// * [parseCompactDate], which will convert a compact date string to a [DateTime]. + String formatCompactDate(T date, MaterialLocalizations localizations); + + /// Converts the given compact date formatted string into a [DateTime]. + /// + /// The format of the string must be a valid compact date format for the + /// given locale. If the text doesn't represent a valid date, `null` will be + /// returned. + /// + /// See also: + /// * [formatCompactDate], which will convert a [DateTime] into a string in the compact format. + T? parseCompactDate(String? inputString, MaterialLocalizations localizations); + + /// The help text used on an empty [InputDatePickerFormField] to indicate + /// to the user the date format being asked for. + String dateHelpText(MaterialLocalizations localizations); +} + +/// A [CalendarDelegate] implementation for the Gregorian calendar system. +/// +/// The Gregorian calendar is the most widely used civil calendar worldwide. +/// This delegate provides standard date interpretation, formatting, and +/// navigation based on the Gregorian system. +/// +/// This delegate is the default calendar system for [CalendarDatePicker]. +/// +/// See also: +/// * [CalendarDelegate], the base class for defining custom calendars. +/// * [CalendarDatePicker], which uses this delegate for date selection. +class GregorianCalendarDelegate extends CalendarDelegate { + /// Creates a calendar delegate that uses the Gregorian calendar and the + /// conventions of the current [MaterialLocalizations]. + const GregorianCalendarDelegate(); + + @override + DateTime now() => DateTime.now(); + + @override + DateTime dateOnly(DateTime date) => DateUtils.dateOnly(date); + + @override + int monthDelta(DateTime startDate, DateTime endDate) => DateUtils.monthDelta(startDate, endDate); + + @override + DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { + return DateUtils.addMonthsToMonthDate(monthDate, monthsToAdd); + } + + @override + DateTime addDaysToDate(DateTime date, int days) => DateUtils.addDaysToDate(date, days); + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return DateUtils.firstDayOffset(year, month, localizations); + } + + /// {@macro flutter.material.date.getDaysInMonth} + @override + int getDaysInMonth(int year, int month) => DateUtils.getDaysInMonth(year, month); + + @override + DateTime getMonth(int year, int month) => DateTime(year, month); + + @override + DateTime getDay(int year, int month, int day) => DateTime(year, month, day); + + @override + String formatMonthYear(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMonthYear(date); + } + + @override + String formatMediumDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatMediumDate(date); + } + + @override + String formatShortMonthDay(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortMonthDay(date); + } + + @override + String formatShortDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatShortDate(date); + } + + @override + String formatFullDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatFullDate(date); + } + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return localizations.formatCompactDate(date); + } + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + return localizations.parseCompactDate(inputString); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return localizations.dateHelpText; + } +} + /// Utility functions for working with dates. abstract final class DateUtils { + /// {@template flutter.material.date.dateOnly} /// Returns a [DateTime] with the date of the original, but time set to /// midnight. + /// {@endtemplate} static DateTime dateOnly(DateTime date) { return DateTime(date.year, date.month, date.day); } + /// {@template flutter.material.date.datesOnly} /// Returns a [DateTimeRange] with the dates of the original, but with times /// set to midnight. /// /// See also: /// * [dateOnly], which does the same thing for a single date. + /// {@endtemplate} static DateTimeRange datesOnly(DateTimeRange range) { return DateTimeRange(start: dateOnly(range.start), end: dateOnly(range.end)); } + /// {@template flutter.material.date.isSameDay} /// Returns true if the two [DateTime] objects have the same day, month, and /// year, or are both null. + /// {@endtemplate} static bool isSameDay(DateTime? dateA, DateTime? dateB) { return dateA?.year == dateB?.year && dateA?.month == dateB?.month && dateA?.day == dateB?.day; } + /// {@template flutter.material.date.isSameMonth} /// Returns true if the two [DateTime] objects have the same month and /// year, or are both null. + /// {@endtemplate} static bool isSameMonth(DateTime? dateA, DateTime? dateB) { return dateA?.year == dateB?.year && dateA?.month == dateB?.month; } + /// {@template flutter.material.date.monthDelta} /// Determines the number of months between two [DateTime] objects. /// /// For example: @@ -51,10 +292,12 @@ abstract final class DateUtils { /// ``` /// /// The value for `delta` would be `7`. + /// {@endtemplate} static int monthDelta(DateTime startDate, DateTime endDate) { return (endDate.year - startDate.year) * 12 + endDate.month - startDate.month; } + /// {@template flutter.material.date.addMonthsToMonthDate} /// Returns a [DateTime] that is [monthDate] with the added number /// of months and the day set to 1 and time set to midnight. /// @@ -67,16 +310,20 @@ abstract final class DateUtils { /// /// `date` would be January 15, 2019. /// `futureDate` would be April 1, 2019 since it adds 3 months. + /// {@endtemplate} static DateTime addMonthsToMonthDate(DateTime monthDate, int monthsToAdd) { return DateTime(monthDate.year, monthDate.month + monthsToAdd); } + /// {@template flutter.material.date.addDaysToDate} /// Returns a [DateTime] with the added number of days and time set to /// midnight. + /// {@endtemplate} static DateTime addDaysToDate(DateTime date, int days) { return DateTime(date.year, date.month, date.day + days); } + /// {@template flutter.material.date.firstDayOffset} /// Computes the offset from the first day of the week that the first day of /// the [month] falls on. /// @@ -105,6 +352,7 @@ abstract final class DateUtils { /// into the [MaterialLocalizations.narrowWeekdays] list. /// - [MaterialLocalizations.narrowWeekdays] list provides localized names of /// days of week, always starting with Sunday and ending with Saturday. + /// {@endtemplate} static int firstDayOffset(int year, int month, MaterialLocalizations localizations) { // 0-based day of week for the month and year, with 0 representing Monday. final int weekdayFromMonday = DateTime(year, month).weekday - 1; @@ -121,11 +369,13 @@ abstract final class DateUtils { return (weekdayFromMonday - firstDayOfWeekIndex) % 7; } + /// {@template flutter.material.date.getDaysInMonth} /// Returns the number of days in a month, according to the proleptic /// Gregorian calendar. /// /// This applies the leap year logic introduced by the Gregorian reforms of /// 1582. It will not give valid results for dates prior to that time. + /// {@endtemplate} static int getDaysInMonth(int year, int month) { if (month == DateTime.february) { final bool isLeapYear = (year % 4 == 0) && (year % 100 != 0) || (year % 400 == 0); @@ -203,15 +453,16 @@ typedef SelectableDayPredicate = bool Function(DateTime day); /// * [showDateRangePicker], which displays a dialog that allows the user to /// select a date range. @immutable -class DateTimeRange { +@optionalTypeArgs +class DateTimeRange { /// Creates a date range for the given start and end [DateTime]. DateTimeRange({required this.start, required this.end}) : assert(!start.isAfter(end)); /// The start of the range of dates. - final DateTime start; + final T start; /// The end of the range of dates. - final DateTime end; + final T end; /// Returns a [Duration] of the time between [start] and [end]. /// diff --git a/packages/flutter/lib/src/material/date_picker.dart b/packages/flutter/lib/src/material/date_picker.dart index e7ccdfc18a003..3b94e1720de27 100644 --- a/packages/flutter/lib/src/material/date_picker.dart +++ b/packages/flutter/lib/src/material/date_picker.dart @@ -123,6 +123,8 @@ const double _fontSizeToScale = 14.0; /// this can be used to only allow weekdays for selection. If provided, it must /// return true for [initialDate]. /// +/// {@macro flutter.material.calendar_date_picker.calendarDelegate} +/// /// The following optional string parameters allow you to override the default /// text used for various parts of the dialog: /// @@ -221,10 +223,11 @@ Future showDatePicker({ final ValueChanged? onDatePickerModeChange, final Icon? switchToInputEntryModeIcon, final Icon? switchToCalendarEntryModeIcon, + final CalendarDelegate calendarDelegate = const GregorianCalendarDelegate(), }) async { - initialDate = initialDate == null ? null : DateUtils.dateOnly(initialDate); - firstDate = DateUtils.dateOnly(firstDate); - lastDate = DateUtils.dateOnly(lastDate); + initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate); + firstDate = calendarDelegate.dateOnly(firstDate); + lastDate = calendarDelegate.dateOnly(lastDate); assert( !lastDate.isBefore(firstDate), 'lastDate $lastDate must be on or after firstDate $firstDate.', @@ -262,6 +265,7 @@ Future showDatePicker({ onDatePickerModeChange: onDatePickerModeChange, switchToInputEntryModeIcon: switchToInputEntryModeIcon, switchToCalendarEntryModeIcon: switchToCalendarEntryModeIcon, + calendarDelegate: calendarDelegate, ); if (textDirection != null) { @@ -328,10 +332,11 @@ class DatePickerDialog extends StatefulWidget { this.switchToInputEntryModeIcon, this.switchToCalendarEntryModeIcon, this.insetPadding = const EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0), - }) : initialDate = initialDate == null ? null : DateUtils.dateOnly(initialDate), - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate), - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()) { + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate == null ? null : calendarDelegate.dateOnly(initialDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { assert( !this.lastDate.isBefore(this.firstDate), 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', @@ -453,6 +458,9 @@ class DatePickerDialog extends StatefulWidget { /// Defaults to `EdgeInsets.symmetric(horizontal: 16.0, vertical: 24.0)`. final EdgeInsets insetPadding; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _DatePickerDialogState(); } @@ -623,6 +631,7 @@ class _DatePickerDialogState extends State with RestorationMix CalendarDatePicker calendarDatePicker() { return CalendarDatePicker( + calendarDelegate: widget.calendarDelegate, key: _calendarPickerKey, initialDate: _selectedDate.value, firstDate: widget.firstDate, @@ -654,6 +663,7 @@ class _DatePickerDialogState extends State with RestorationMix child: MediaQuery.withClampedTextScaling( maxScaleFactor: 2.0, child: InputDatePickerFormField( + calendarDelegate: widget.calendarDelegate, initialDate: _selectedDate.value, firstDate: widget.firstDate, lastDate: widget.lastDate, @@ -716,7 +726,9 @@ class _DatePickerDialogState extends State with RestorationMix ? localizations.datePickerHelpText : localizations.datePickerHelpText.toUpperCase()), titleText: - _selectedDate.value == null ? '' : localizations.formatMediumDate(_selectedDate.value!), + _selectedDate.value == null + ? '' + : widget.calendarDelegate.formatMediumDate(_selectedDate.value!, localizations), titleStyle: headlineStyle, orientation: orientation, isShort: orientation == Orientation.landscape, @@ -1076,6 +1088,8 @@ typedef SelectableDayForRangePredicate = /// /// {@macro flutter.material.date_picker.switchToCalendarEntryModeIcon} /// +/// {@macro flutter.material.calendar_date_picker.calendarDelegate} +/// /// The following optional string parameters allow you to override the default /// text used for various parts of the dialog: /// @@ -1173,10 +1187,11 @@ Future showDateRangePicker({ final Icon? switchToInputEntryModeIcon, final Icon? switchToCalendarEntryModeIcon, SelectableDayForRangePredicate? selectableDayPredicate, + CalendarDelegate calendarDelegate = const GregorianCalendarDelegate(), }) async { - initialDateRange = initialDateRange == null ? null : DateUtils.datesOnly(initialDateRange); - firstDate = DateUtils.dateOnly(firstDate); - lastDate = DateUtils.dateOnly(lastDate); + initialDateRange = initialDateRange == null ? null : calendarDelegate.datesOnly(initialDateRange); + firstDate = calendarDelegate.dateOnly(firstDate); + lastDate = calendarDelegate.dateOnly(lastDate); assert( !lastDate.isBefore(firstDate), 'lastDate $lastDate must be on or after firstDate $firstDate.', @@ -1213,7 +1228,7 @@ Future showDateRangePicker({ selectableDayPredicate(initialDateRange.end, initialDateRange.start, initialDateRange.end), "initialDateRange's end date must be selectable.", ); - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()); + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()); assert(debugCheckHasMaterialLocalizations(context)); Widget dialog = DateRangePickerDialog( @@ -1270,14 +1285,15 @@ Future showDateRangePicker({ /// (i.e. 'Jan 21, 2020'). String _formatRangeStartDate( MaterialLocalizations localizations, + CalendarDelegate calendarDelegate, DateTime? startDate, DateTime? endDate, ) { return startDate == null ? localizations.dateRangeStartLabel : (endDate == null || startDate.year == endDate.year) - ? localizations.formatShortMonthDay(startDate) - : localizations.formatShortDate(startDate); + ? calendarDelegate.formatShortMonthDay(startDate, localizations) + : calendarDelegate.formatShortDate(startDate, localizations); } /// Returns an locale-appropriate string to describe the end of a date range. @@ -1288,6 +1304,7 @@ String _formatRangeStartDate( /// include the year (i.e. 'Jan 21, 2020'). String _formatRangeEndDate( MaterialLocalizations localizations, + CalendarDelegate calendarDelegate, DateTime? startDate, DateTime? endDate, DateTime currentDate, @@ -1295,8 +1312,8 @@ String _formatRangeEndDate( return endDate == null ? localizations.dateRangeEndLabel : (startDate != null && startDate.year == endDate.year && startDate.year == currentDate.year) - ? localizations.formatShortMonthDay(endDate) - : localizations.formatShortDate(endDate); + ? calendarDelegate.formatShortMonthDay(endDate, localizations) + : calendarDelegate.formatShortDate(endDate, localizations); } /// A Material-style date range picker dialog. @@ -1333,6 +1350,7 @@ class DateRangePickerDialog extends StatefulWidget { this.switchToInputEntryModeIcon, this.switchToCalendarEntryModeIcon, this.selectableDayPredicate, + this.calendarDelegate = const GregorianCalendarDelegate(), }); /// The date range that the date range picker starts with when it opens. @@ -1464,6 +1482,9 @@ class DateRangePickerDialog extends StatefulWidget { /// Function to provide full control over which [DateTime] can be selected. final SelectableDayForRangePredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _DateRangePickerDialogState(); } @@ -1598,6 +1619,7 @@ class _DateRangePickerDialogState extends State with Rest case DatePickerEntryMode.calendarOnly: contents = _CalendarRangePickerDialog( key: _calendarPickerKey, + calendarDelegate: widget.calendarDelegate, selectedStartDate: _selectedStart.value, selectedEndDate: _selectedEnd.value, firstDate: widget.firstDate, @@ -1641,6 +1663,7 @@ class _DateRangePickerDialogState extends State with Rest case DatePickerEntryMode.input: case DatePickerEntryMode.inputOnly: contents = _InputDateRangePickerDialog( + calendarDelegate: widget.calendarDelegate, selectedStartDate: _selectedStart.value, selectedEndDate: _selectedEnd.value, currentDate: widget.currentDate, @@ -1656,6 +1679,7 @@ class _DateRangePickerDialogState extends State with Rest const Spacer(), _InputDateRangePicker( key: _inputPickerKey, + calendarDelegate: widget.calendarDelegate, initialStartDate: _selectedStart.value, initialEndDate: _selectedEnd.value, firstDate: widget.firstDate, @@ -1763,6 +1787,7 @@ class _CalendarRangePickerDialog extends StatelessWidget { required this.confirmText, required this.helpText, required this.selectableDayPredicate, + required this.calendarDelegate, this.entryModeButton, }); @@ -1778,6 +1803,7 @@ class _CalendarRangePickerDialog extends StatelessWidget { final VoidCallback? onCancel; final String confirmText; final String helpText; + final CalendarDelegate calendarDelegate; final Widget? entryModeButton; @override @@ -1802,14 +1828,16 @@ class _CalendarRangePickerDialog extends StatelessWidget { ?.apply(color: headerForeground); final String startDateText = _formatRangeStartDate( localizations, + calendarDelegate, selectedStartDate, selectedEndDate, ); final String endDateText = _formatRangeEndDate( localizations, + calendarDelegate, selectedStartDate, selectedEndDate, - DateTime.now(), + calendarDelegate.now(), ); final TextStyle? startDateStyle = headlineStyle?.apply( color: selectedStartDate != null ? headerForeground : headerDisabledForeground, @@ -1902,6 +1930,7 @@ class _CalendarRangePickerDialog extends StatelessWidget { onStartDateChanged: onStartDateChanged, onEndDateChanged: onEndDateChanged, selectableDayPredicate: selectableDayPredicate, + calendarDelegate: calendarDelegate, ), ), ); @@ -1931,11 +1960,13 @@ class _CalendarDateRangePicker extends StatefulWidget { DateTime? currentDate, required this.onStartDateChanged, required this.onEndDateChanged, - }) : initialStartDate = initialStartDate != null ? DateUtils.dateOnly(initialStartDate) : null, - initialEndDate = initialEndDate != null ? DateUtils.dateOnly(initialEndDate) : null, - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate), - currentDate = DateUtils.dateOnly(currentDate ?? DateTime.now()) { + required this.calendarDelegate, + }) : initialStartDate = + initialStartDate != null ? calendarDelegate.dateOnly(initialStartDate) : null, + initialEndDate = initialEndDate != null ? calendarDelegate.dateOnly(initialEndDate) : null, + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate), + currentDate = calendarDelegate.dateOnly(currentDate ?? calendarDelegate.now()) { assert( this.initialStartDate == null || this.initialEndDate == null || @@ -1969,6 +2000,9 @@ class _CalendarDateRangePicker extends StatefulWidget { /// Called when the user changes the end date of the selected range. final ValueChanged? onEndDateChanged; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State<_CalendarDateRangePicker> createState() => _CalendarDateRangePickerState(); } @@ -1994,7 +2028,7 @@ class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { // divide the list of months into two `SliverList`s. final DateTime initialDate = widget.initialStartDate ?? widget.currentDate; if (!initialDate.isBefore(widget.firstDate) && !initialDate.isAfter(widget.lastDate)) { - _initialMonthIndex = DateUtils.monthDelta(widget.firstDate, initialDate); + _initialMonthIndex = widget.calendarDelegate.monthDelta(widget.firstDate, initialDate); } _showWeekBottomDivider = _initialMonthIndex != 0; @@ -2018,7 +2052,8 @@ class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { } } - int get _numberOfMonths => DateUtils.monthDelta(widget.firstDate, widget.lastDate) + 1; + int get _numberOfMonths => + widget.calendarDelegate.monthDelta(widget.firstDate, widget.lastDate) + 1; void _vibrate() { switch (Theme.of(context).platform) { @@ -2062,8 +2097,12 @@ class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { Widget _buildMonthItem(BuildContext context, int index, bool beforeInitialMonth) { final int monthIndex = beforeInitialMonth ? _initialMonthIndex - index - 1 : _initialMonthIndex + index; - final DateTime month = DateUtils.addMonthsToMonthDate(widget.firstDate, monthIndex); + final DateTime month = widget.calendarDelegate.addMonthsToMonthDate( + widget.firstDate, + monthIndex, + ); return _MonthItem( + calendarDelegate: widget.calendarDelegate, selectedDateStart: _startDate, selectedDateEnd: _endDate, currentDate: widget.currentDate, @@ -2085,6 +2124,7 @@ class _CalendarDateRangePickerState extends State<_CalendarDateRangePicker> { if (_showWeekBottomDivider) const Divider(height: 0), Expanded( child: _CalendarKeyboardNavigator( + calendarDelegate: widget.calendarDelegate, firstDate: widget.firstDate, lastDate: widget.lastDate, initialFocusedDay: _startDate ?? widget.initialStartDate ?? widget.currentDate, @@ -2125,12 +2165,14 @@ class _CalendarKeyboardNavigator extends StatefulWidget { required this.firstDate, required this.lastDate, required this.initialFocusedDay, + required this.calendarDelegate, }); final Widget child; final DateTime firstDate; final DateTime lastDate; final DateTime initialFocusedDay; + final CalendarDelegate calendarDelegate; @override _CalendarKeyboardNavigatorState createState() => _CalendarKeyboardNavigatorState(); @@ -2231,7 +2273,7 @@ class _CalendarKeyboardNavigatorState extends State<_CalendarKeyboardNavigator> DateTime? _nextDateInDirection(DateTime date, TraversalDirection direction) { final TextDirection textDirection = Directionality.of(context); - final DateTime nextDate = DateUtils.addDaysToDate( + final DateTime nextDate = widget.calendarDelegate.addDaysToDate( date, _dayDirectionOffset(direction, textDirection), ); @@ -2249,6 +2291,7 @@ class _CalendarKeyboardNavigatorState extends State<_CalendarKeyboardNavigator> focusNode: _dayGridFocus, onFocusChange: _handleGridFocusChange, child: _FocusedDate( + calendarDelegate: widget.calendarDelegate, date: _dayGridFocus.hasFocus ? _focusedDay : null, scrollDirection: _dayGridFocus.hasFocus ? _dayTraversalDirection : null, child: widget.child, @@ -2260,14 +2303,20 @@ class _CalendarKeyboardNavigatorState extends State<_CalendarKeyboardNavigator> /// InheritedWidget indicating what the current focused date is for its children. // See also: _FocusedDate in calendar_date_picker.dart class _FocusedDate extends InheritedWidget { - const _FocusedDate({required super.child, this.date, this.scrollDirection}); + const _FocusedDate({ + required super.child, + required this.calendarDelegate, + this.date, + this.scrollDirection, + }); + final CalendarDelegate calendarDelegate; final DateTime? date; final TraversalDirection? scrollDirection; @override bool updateShouldNotify(_FocusedDate oldWidget) { - return !DateUtils.isSameDay(date, oldWidget.date) || + return !calendarDelegate.isSameDay(date, oldWidget.date) || scrollDirection != oldWidget.scrollDirection; } @@ -2464,6 +2513,7 @@ class _MonthItem extends StatefulWidget { required this.lastDate, required this.displayedMonth, required this.selectableDayPredicate, + required this.calendarDelegate, }) : assert(!firstDate.isAfter(lastDate)), assert(selectedDateStart == null || !selectedDateStart.isBefore(firstDate)), assert(selectedDateEnd == null || !selectedDateEnd.isBefore(firstDate)), @@ -2502,6 +2552,9 @@ class _MonthItem extends StatefulWidget { final SelectableDayForRangePredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override _MonthItemState createState() => _MonthItemState(); } @@ -2513,7 +2566,7 @@ class _MonthItemState extends State<_MonthItem> { @override void initState() { super.initState(); - final int daysInMonth = DateUtils.getDaysInMonth( + final int daysInMonth = widget.calendarDelegate.getDaysInMonth( widget.displayedMonth.year, widget.displayedMonth.month, ); @@ -2528,7 +2581,8 @@ class _MonthItemState extends State<_MonthItem> { super.didChangeDependencies(); // Check to see if the focused date is in this month, if so focus it. final DateTime? focusedDate = _FocusedDate.maybeOf(context)?.date; - if (focusedDate != null && DateUtils.isSameMonth(widget.displayedMonth, focusedDate)) { + if (focusedDate != null && + widget.calendarDelegate.isSameMonth(widget.displayedMonth, focusedDate)) { _dayFocusNodes[focusedDate.day - 1].requestFocus(); } } @@ -2596,9 +2650,10 @@ class _MonthItemState extends State<_MonthItem> { dayToBuild.isBefore(widget.selectedDateEnd!); final bool isOneDayRange = isRangeSelected && widget.selectedDateStart == widget.selectedDateEnd; - final bool isToday = DateUtils.isSameDay(widget.currentDate, dayToBuild); + final bool isToday = widget.calendarDelegate.isSameDay(widget.currentDate, dayToBuild); return _DayItem( + calendarDelegate: widget.calendarDelegate, day: dayToBuild, focusNode: _dayFocusNodes[day - 1], onChanged: widget.onChanged, @@ -2626,8 +2681,8 @@ class _MonthItemState extends State<_MonthItem> { final MaterialLocalizations localizations = MaterialLocalizations.of(context); final int year = widget.displayedMonth.year; final int month = widget.displayedMonth.month; - final int daysInMonth = DateUtils.getDaysInMonth(year, month); - final int dayOffset = DateUtils.firstDayOffset(year, month, localizations); + final int daysInMonth = widget.calendarDelegate.getDaysInMonth(year, month); + final int dayOffset = widget.calendarDelegate.firstDayOffset(year, month, localizations); final int weeks = ((daysInMonth + dayOffset) / DateTime.daysPerWeek).ceil(); final double gridHeight = weeks * _monthItemRowHeight + (weeks - 1) * _monthItemSpaceBetweenRows; @@ -2639,7 +2694,7 @@ class _MonthItemState extends State<_MonthItem> { if (day < 1) { dayItems.add(const LimitedBox(maxWidth: 0.0, maxHeight: 0.0, child: SizedBox.expand())); } else { - final DateTime dayToBuild = DateTime(year, month, day); + final DateTime dayToBuild = widget.calendarDelegate.getDay(year, month, day); final Widget dayItem = _buildDayItem(context, dayToBuild, dayOffset, daysInMonth); dayItems.add(dayItem); } @@ -2653,7 +2708,11 @@ class _MonthItemState extends State<_MonthItem> { final int end = math.min(start + DateTime.daysPerWeek, dayItems.length); final List weekList = dayItems.sublist(start, end); - final DateTime dateAfterLeadingPadding = DateTime(year, month, start - dayOffset + 1); + final DateTime dateAfterLeadingPadding = widget.calendarDelegate.getDay( + year, + month, + start - dayOffset + 1, + ); // Only color the edge container if it is after the start date and // on/before the end date. final bool isLeadingInRange = @@ -2668,7 +2727,11 @@ class _MonthItemState extends State<_MonthItem> { // partial week. if (end < dayItems.length || (end == dayItems.length && dayItems.length % DateTime.daysPerWeek == 0)) { - final DateTime dateBeforeTrailingPadding = DateTime(year, month, end - dayOffset); + final DateTime dateBeforeTrailingPadding = widget.calendarDelegate.getDay( + year, + month, + end - dayOffset, + ); // Only color the edge container if it is on/after the start date and // before the end date. final bool isTrailingInRange = @@ -2696,7 +2759,7 @@ class _MonthItemState extends State<_MonthItem> { alignment: AlignmentDirectional.centerStart, child: ExcludeSemantics( child: Text( - localizations.formatMonthYear(widget.displayedMonth), + widget.calendarDelegate.formatMonthYear(widget.displayedMonth, localizations), style: textTheme.bodyMedium!.apply(color: themeData.colorScheme.onSurface), ), ), @@ -2731,6 +2794,7 @@ class _DayItem extends StatefulWidget { required this.isInRange, required this.isOneDayRange, required this.isToday, + required this.calendarDelegate, }); final DateTime day; @@ -2757,6 +2821,8 @@ class _DayItem extends StatefulWidget { final bool isToday; + final CalendarDelegate calendarDelegate; + @override State<_DayItem> createState() => _DayItemState(); } @@ -2872,7 +2938,7 @@ class _DayItemState extends State<_DayItem> { // formatted full date. final String semanticLabelSuffix = widget.isToday ? ', ${localizations.currentDateLabel}' : ''; String semanticLabel = - '$dayText, ${localizations.formatFullDate(widget.day)}$semanticLabelSuffix'; + '$dayText, ${widget.calendarDelegate.formatFullDate(widget.day, localizations)}$semanticLabelSuffix'; if (widget.isSelectedDayStart) { semanticLabel = localizations.dateRangeStartDateSemanticLabel(semanticLabel); } else if (widget.isSelectedDayEnd) { @@ -2987,6 +3053,7 @@ class _InputDateRangePickerDialog extends StatelessWidget { required this.cancelText, required this.helpText, required this.entryModeButton, + required this.calendarDelegate, }); final DateTime? selectedStartDate; @@ -2999,11 +3066,12 @@ class _InputDateRangePickerDialog extends StatelessWidget { final String? cancelText; final String? helpText; final Widget? entryModeButton; + final CalendarDelegate calendarDelegate; String _formatDateRange(BuildContext context, DateTime? start, DateTime? end, DateTime now) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - final String startText = _formatRangeStartDate(localizations, start, end); - final String endText = _formatRangeEndDate(localizations, start, end, now); + final String startText = _formatRangeStartDate(localizations, calendarDelegate, start, end); + final String endText = _formatRangeEndDate(localizations, calendarDelegate, start, end, now); if (start == null || end == null) { return localizations.unspecifiedDateRange; } @@ -3042,7 +3110,7 @@ class _InputDateRangePickerDialog extends StatelessWidget { ); final String semanticDateText = selectedStartDate != null && selectedEndDate != null - ? '${localizations.formatMediumDate(selectedStartDate!)} – ${localizations.formatMediumDate(selectedEndDate!)}' + ? '${calendarDelegate.formatMediumDate(selectedStartDate!, localizations)} – ${calendarDelegate.formatMediumDate(selectedEndDate!, localizations)}' : ''; final Widget header = _DatePickerHeader( @@ -3149,6 +3217,7 @@ class _InputDateRangePicker extends StatefulWidget { required this.onStartDateChanged, required this.onEndDateChanged, required this.selectableDayPredicate, + required this.calendarDelegate, this.helpText, this.errorFormatText, this.errorInvalidText, @@ -3160,10 +3229,11 @@ class _InputDateRangePicker extends StatefulWidget { this.autofocus = false, this.autovalidate = false, this.keyboardType = TextInputType.datetime, - }) : initialStartDate = initialStartDate == null ? null : DateUtils.dateOnly(initialStartDate), - initialEndDate = initialEndDate == null ? null : DateUtils.dateOnly(initialEndDate), - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate); + }) : initialStartDate = + initialStartDate == null ? null : calendarDelegate.dateOnly(initialStartDate), + initialEndDate = initialEndDate == null ? null : calendarDelegate.dateOnly(initialEndDate), + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate); /// The [DateTime] that represents the start of the initial date range selection. final DateTime? initialStartDate; @@ -3224,6 +3294,9 @@ class _InputDateRangePicker extends StatefulWidget { final SelectableDayForRangePredicate? selectableDayPredicate; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override _InputDateRangePickerState createState() => _InputDateRangePickerState(); } @@ -3262,14 +3335,14 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { super.didChangeDependencies(); final MaterialLocalizations localizations = MaterialLocalizations.of(context); if (_startDate != null) { - _startInputText = localizations.formatCompactDate(_startDate!); + _startInputText = widget.calendarDelegate.formatCompactDate(_startDate!, localizations); final bool selectText = widget.autofocus && !_autoSelected; _updateController(_startController, _startInputText, selectText); _autoSelected = selectText; } if (_endDate != null) { - _endInputText = localizations.formatCompactDate(_endDate!); + _endInputText = widget.calendarDelegate.formatCompactDate(_endDate!, localizations); _updateController(_endController, _endInputText, false); } } @@ -3298,7 +3371,7 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { DateTime? _parseDate(String? text) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - return localizations.parseCompactDate(text); + return widget.calendarDelegate.parseCompactDate(text, localizations); } String? _validateDate(DateTime? date) { @@ -3371,7 +3444,8 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { decoration: InputDecoration( border: inputBorder, filled: inputTheme.filled, - hintText: widget.fieldStartHintText ?? localizations.dateHelpText, + hintText: + widget.fieldStartHintText ?? widget.calendarDelegate.dateHelpText(localizations), labelText: widget.fieldStartLabelText ?? localizations.dateRangeStartLabel, errorText: _startErrorText, ), @@ -3387,7 +3461,8 @@ class _InputDateRangePickerState extends State<_InputDateRangePicker> { decoration: InputDecoration( border: inputBorder, filled: inputTheme.filled, - hintText: widget.fieldEndHintText ?? localizations.dateHelpText, + hintText: + widget.fieldEndHintText ?? widget.calendarDelegate.dateHelpText(localizations), labelText: widget.fieldEndLabelText ?? localizations.dateRangeEndLabel, errorText: _endErrorText, ), diff --git a/packages/flutter/lib/src/material/input_date_picker_form_field.dart b/packages/flutter/lib/src/material/input_date_picker_form_field.dart index 6f34b64acdafb..a7623e6f679df 100644 --- a/packages/flutter/lib/src/material/input_date_picker_form_field.dart +++ b/packages/flutter/lib/src/material/input_date_picker_form_field.dart @@ -62,9 +62,10 @@ class InputDatePickerFormField extends StatefulWidget { this.autofocus = false, this.acceptEmptyDate = false, this.focusNode, - }) : initialDate = initialDate != null ? DateUtils.dateOnly(initialDate) : null, - firstDate = DateUtils.dateOnly(firstDate), - lastDate = DateUtils.dateOnly(lastDate) { + this.calendarDelegate = const GregorianCalendarDelegate(), + }) : initialDate = initialDate != null ? calendarDelegate.dateOnly(initialDate) : null, + firstDate = calendarDelegate.dateOnly(firstDate), + lastDate = calendarDelegate.dateOnly(lastDate) { assert( !this.lastDate.isBefore(this.firstDate), 'lastDate ${this.lastDate} must be on or after firstDate ${this.firstDate}.', @@ -146,6 +147,9 @@ class InputDatePickerFormField extends StatefulWidget { /// {@macro flutter.widgets.Focus.focusNode} final FocusNode? focusNode; + /// {@macro flutter.material.calendar_date_picker.calendarDelegate} + final CalendarDelegate calendarDelegate; + @override State createState() => _InputDatePickerFormFieldState(); } @@ -191,7 +195,7 @@ class _InputDatePickerFormFieldState extends State { void _updateValueForSelectedDate() { if (_selectedDate != null) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - _inputText = localizations.formatCompactDate(_selectedDate!); + _inputText = widget.calendarDelegate.formatCompactDate(_selectedDate!, localizations); TextEditingValue textEditingValue = TextEditingValue(text: _inputText!); // Select the new text if we are auto focused and haven't selected the text before. if (widget.autofocus && !_autoSelected) { @@ -209,7 +213,7 @@ class _InputDatePickerFormFieldState extends State { DateTime? _parseDate(String? text) { final MaterialLocalizations localizations = MaterialLocalizations.of(context); - return localizations.parseCompactDate(text); + return widget.calendarDelegate.parseCompactDate(text, localizations); } bool _isValidAcceptableDate(DateTime? date) { @@ -265,7 +269,7 @@ class _InputDatePickerFormFieldState extends State { container: true, child: TextFormField( decoration: InputDecoration( - hintText: widget.fieldHintText ?? localizations.dateHelpText, + hintText: widget.fieldHintText ?? widget.calendarDelegate.dateHelpText(localizations), labelText: widget.fieldLabelText ?? localizations.dateInputLabel, ).applyDefaults( inputTheme diff --git a/packages/flutter/test/material/calendar_date_picker_test.dart b/packages/flutter/test/material/calendar_date_picker_test.dart index 19486bf43e337..0af4a8ca288da 100644 --- a/packages/flutter/test/material/calendar_date_picker_test.dart +++ b/packages/flutter/test/material/calendar_date_picker_test.dart @@ -1493,4 +1493,79 @@ void main() { expect(selectedYear, equals(DateTime(2018, DateTime.june))); }); }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: CalendarDatePicker( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2025, DateTime.may), + onDateChanged: (DateTime value) {}, + ), + ), + ), + ); + + final CalendarDatePicker calendarPicker = tester.widget(find.byType(CalendarDatePicker)); + expect(calendarPicker.calendarDelegate, isA()); + + final Finder datePickerModeToggleButton = find.descendant( + of: find.byType(InkWell), + matching: find.text('February 2025'), + ); + await tester.tap(datePickerModeToggleButton); + await tester.pumpAndSettle(); + + final YearPicker yearPicker = tester.widget(find.byType(YearPicker)); + expect(yearPicker.calendarDelegate, isA()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: CalendarDatePicker( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2025, DateTime.may), + onDateChanged: (DateTime value) {}, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final CalendarDatePicker calendarPicker = tester.widget(find.byType(CalendarDatePicker)); + expect(calendarPicker.calendarDelegate, isA()); + + final Finder datePickerModeToggleButton = find.descendant( + of: find.byType(InkWell), + matching: find.text('February 2025'), + ); + await tester.tap(datePickerModeToggleButton); + await tester.pumpAndSettle(); + + final YearPicker yearPicker = tester.widget(find.byType(YearPicker)); + expect(yearPicker.calendarDelegate, isA()); + }); + }); +} + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } } diff --git a/packages/flutter/test/material/date_picker_test.dart b/packages/flutter/test/material/date_picker_test.dart index 9c00c6158f815..dc643e80d2b09 100644 --- a/packages/flutter/test/material/date_picker_test.dart +++ b/packages/flutter/test/material/date_picker_test.dart @@ -2636,6 +2636,86 @@ void main() { await gesture.up(); }); }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + final DatePickerDialog dialog = tester.widget(find.byType(DatePickerDialog)); + expect(dialog.calendarDelegate, isA()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final DatePickerDialog dialog = tester.widget(find.byType(DatePickerDialog)); + expect(dialog.calendarDelegate, isA()); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + Text getLastDayText() { + final Finder dayFinder = find.descendant(of: find.byType(Ink), matching: find.byType(Text)); + return tester.widget(dayFinder.last); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DatePickerDialog( + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder nextMonthButton = find.byIcon(Icons.chevron_right); + + Text lastDayText = getLastDayText(); + expect(find.text('January 2016'), findsOneWidget); + expect(lastDayText.data, equals('28')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(); + expect(find.text('February 2016'), findsOneWidget); + expect(lastDayText.data, equals('21')); + + await tester.tap(nextMonthButton); + await tester.pumpAndSettle(); + + lastDayText = getLastDayText(); + expect(find.text('March 2016'), findsOneWidget); + expect(lastDayText.data, equals('28')); + }); + }); } class _RestorableDatePickerDialogTestWidget extends StatefulWidget { @@ -2753,3 +2833,17 @@ class _DatePickerObserver extends NavigatorObserver { super.didPop(route, previousRoute); } } + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } +} diff --git a/packages/flutter/test/material/date_range_picker_test.dart b/packages/flutter/test/material/date_range_picker_test.dart index 78233fcd2570f..ce876fe84c7a2 100644 --- a/packages/flutter/test/material/date_range_picker_test.dart +++ b/packages/flutter/test/material/date_range_picker_test.dart @@ -1807,6 +1807,101 @@ void main() { }); }); }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + ), + ), + ), + ); + + final DateRangePickerDialog dialog = tester.widget(find.byType(DateRangePickerDialog)); + expect(dialog.calendarDelegate, isA()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final DateRangePickerDialog dialog = tester.widget(find.byType(DateRangePickerDialog)); + expect(dialog.calendarDelegate, isA()); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + Finder getMonthItem() { + final Finder dayItem = find.descendant( + of: find.byType(ConstrainedBox), + matching: find.text('1'), + ); + return find.ancestor(of: dayItem, matching: find.byType(Column)); + } + + int getDayCount(Finder parent) { + final Finder dayItem = find.descendant( + of: parent, + matching: find.descendant(of: find.byType(InkResponse), matching: find.byType(Text)), + ); + return tester.widgetList(dayItem).length; + } + + Text getMonthYear(Finder parent) { + return tester.widget( + find + .descendant( + of: parent, + matching: find.descendant( + of: find.byType(ConstrainedBox), + matching: find.byType(Text), + ), + ) + .first, + ); + } + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: DateRangePickerDialog( + initialDateRange: initialDateRange, + firstDate: firstDate, + lastDate: lastDate, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder monthItem = getMonthItem(); + + final Finder firstMonthItem = monthItem.at(0); + expect(getMonthYear(firstMonthItem).data, 'January 2016'); + expect(getDayCount(firstMonthItem), 28); + + final Finder secondMonthItem = monthItem.at(2); + expect(getMonthYear(secondMonthItem).data, 'February 2016'); + expect(getDayCount(secondMonthItem), 21); + }); + }); } class _RestorableDateRangePickerDialogTestWidget extends StatefulWidget { @@ -1908,3 +2003,17 @@ class _RestorableDateRangePickerDialogTestWidgetState ); } } + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + int getDaysInMonth(int year, int month) { + return month.isEven ? 21 : 28; + } + + @override + int firstDayOffset(int year, int month, MaterialLocalizations localizations) { + return 1; + } +} diff --git a/packages/flutter/test/material/input_date_picker_form_field_test.dart b/packages/flutter/test/material/input_date_picker_form_field_test.dart index ca7ce6b402374..b57de89ad46e1 100644 --- a/packages/flutter/test/material/input_date_picker_form_field_test.dart +++ b/packages/flutter/test/material/input_date_picker_form_field_test.dart @@ -413,4 +413,124 @@ void main() { await tester.pumpAndSettle(); expect(focusNode.hasFocus, isFalse); }); + + group('Calendar Delegate', () { + testWidgets('Defaults to Gregorian calendar system', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + ), + ), + ), + ); + + final InputDatePickerFormField inputDatePickerField = tester.widget( + find.byType(InputDatePickerFormField), + ); + expect(inputDatePickerField.calendarDelegate, isA()); + }); + + testWidgets('Using custom calendar delegate implementation', (WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final InputDatePickerFormField inputDatePickerField = tester.widget( + find.byType(InputDatePickerFormField), + ); + expect(inputDatePickerField.calendarDelegate, isA()); + }); + + testWidgets('Displays calendar based on the calendar delegate', (WidgetTester tester) async { + DateTime? selectedDate; + + await tester.pumpWidget( + MaterialApp( + theme: ThemeData(useMaterial3: true), + home: Material( + child: InputDatePickerFormField( + initialDate: DateTime(2025, DateTime.february, 26), + firstDate: DateTime(2025, DateTime.february), + lastDate: DateTime(2026, DateTime.may), + onDateSubmitted: (DateTime value) { + selectedDate = value; + }, + calendarDelegate: const TestCalendarDelegate(), + ), + ), + ), + ); + + final Finder dateInput1 = find.descendant( + of: find.byType(TextField), + matching: find.text('2025..2..26'), + ); + expect(dateInput1, findsOneWidget); + + await tester.tap(dateInput1); + await tester.pumpAndSettle(); + + await tester.enterText(dateInput1, '2025..3..10'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(selectedDate, DateTime(2025, DateTime.march, 10)); + + final Finder dateInput2 = find.descendant( + of: find.byType(TextField), + matching: find.text('2025..3..10'), + ); + expect(dateInput2, findsOneWidget); + + await tester.tap(dateInput2); + await tester.pumpAndSettle(); + + await tester.enterText(dateInput2, '2025..4..21'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(selectedDate, DateTime(2025, DateTime.april, 21)); + }); + }); +} + +class TestCalendarDelegate extends GregorianCalendarDelegate { + const TestCalendarDelegate(); + + @override + String formatCompactDate(DateTime date, MaterialLocalizations localizations) { + return '${date.year}..${date.month}..${date.day}'; + } + + @override + DateTime? parseCompactDate(String? inputString, MaterialLocalizations localizations) { + final List parts = inputString!.split('..'); + if (parts.length != 3) { + return null; + } + final int year = int.tryParse(parts[0]) ?? 0; + final int month = int.tryParse(parts[1]) ?? 0; + final int day = int.tryParse(parts[2]) ?? 0; + return DateTime(year, month, day); + } + + @override + String dateHelpText(MaterialLocalizations localizations) { + return 'yyyy..mm..dd'; + } } From a7e276a20d4b0cd9601c66cff461ea2d83ad34e7 Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Fri, 7 Mar 2025 18:43:08 -0800 Subject: [PATCH 12/21] [Impeller] add capability check for extended range formats. (#164817) Fixes https://github.com/flutter/flutter/issues/164794 We support devices that do not support XR formats. If we try to decode to an XR format this will fail at runtime. --- .../renderer/backend/gles/capabilities_gles.cc | 4 ++++ .../renderer/backend/gles/capabilities_gles.h | 3 +++ .../renderer/backend/metal/context_mtl.mm | 11 +++++++++++ .../renderer/backend/vulkan/capabilities_vk.cc | 4 ++++ .../renderer/backend/vulkan/capabilities_vk.h | 3 +++ .../src/flutter/impeller/renderer/capabilities.cc | 15 +++++++++++++++ .../src/flutter/impeller/renderer/capabilities.h | 10 ++++++++++ .../impeller/renderer/capabilities_unittests.cc | 1 + .../src/flutter/impeller/renderer/testing/mocks.h | 1 + .../lib/ui/painting/image_decoder_impeller.cc | 9 +++++---- .../lib/ui/painting/image_decoder_impeller.h | 5 ++++- 11 files changed, 61 insertions(+), 5 deletions(-) diff --git a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc index 67f73eff4e07f..9bc8da8538395 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc +++ b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.cc @@ -219,6 +219,10 @@ bool CapabilitiesGLES::SupportsPrimitiveRestart() const { return false; } +bool CapabilitiesGLES::SupportsExtendedRangeFormats() const { + return false; +} + PixelFormat CapabilitiesGLES::GetDefaultGlyphAtlasFormat() const { return default_glyph_atlas_format_; } diff --git a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h index fa39cfecab637..8783aa469893a 100644 --- a/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h +++ b/engine/src/flutter/impeller/renderer/backend/gles/capabilities_gles.h @@ -116,6 +116,9 @@ class CapabilitiesGLES final // |Capabilities| bool SupportsPrimitiveRestart() const override; + // |Capabilities| + bool SupportsExtendedRangeFormats() const override; + // |Capabilities| PixelFormat GetDefaultColorFormat() const override; diff --git a/engine/src/flutter/impeller/renderer/backend/metal/context_mtl.mm b/engine/src/flutter/impeller/renderer/backend/metal/context_mtl.mm index a4083bfa63eec..7421a330bb92c 100644 --- a/engine/src/flutter/impeller/renderer/backend/metal/context_mtl.mm +++ b/engine/src/flutter/impeller/renderer/backend/metal/context_mtl.mm @@ -52,6 +52,15 @@ static bool DeviceSupportsComputeSubgroups(id device) { return supports_subgroups; } +// See "Extended Range and wide color pixel formats" in the metal feature set +// tables. +static bool DeviceSupportsExtendedRangeFormats(id device) { + if (@available(macOS 10.15, iOS 13, tvOS 13, *)) { + return [device supportsFamily:MTLGPUFamilyApple3]; + } + return false; +} + static std::unique_ptr InferMetalCapabilities( id device, PixelFormat color_format) { @@ -71,6 +80,8 @@ static bool DeviceSupportsComputeSubgroups(id device) { .SetDefaultGlyphAtlasFormat(PixelFormat::kA8UNormInt) .SetSupportsTriangleFan(false) .SetMaximumRenderPassAttachmentSize(DeviceMaxTextureSizeSupported(device)) + .SetSupportsExtendedRangeFormats( + DeviceSupportsExtendedRangeFormats(device)) .Build(); } diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.cc b/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.cc index 5944feecf89d2..6e59d9f678cd5 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.cc +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.cc @@ -798,4 +798,8 @@ bool CapabilitiesVK::SupportsExternalSemaphoreExtensions() const { return supports_external_fence_and_semaphore_; } +bool CapabilitiesVK::SupportsExtendedRangeFormats() const { + return false; +} + } // namespace impeller diff --git a/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.h b/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.h index 38a984c8677c5..5b99f704289a9 100644 --- a/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.h +++ b/engine/src/flutter/impeller/renderer/backend/vulkan/capabilities_vk.h @@ -260,6 +260,9 @@ class CapabilitiesVK final : public Capabilities, // |Capabilities| bool SupportsPrimitiveRestart() const override; + // |Capabilities| + bool SupportsExtendedRangeFormats() const override; + // |Capabilities| PixelFormat GetDefaultColorFormat() const override; diff --git a/engine/src/flutter/impeller/renderer/capabilities.cc b/engine/src/flutter/impeller/renderer/capabilities.cc index 5637d8458c35e..3658086a3cf8b 100644 --- a/engine/src/flutter/impeller/renderer/capabilities.cc +++ b/engine/src/flutter/impeller/renderer/capabilities.cc @@ -91,6 +91,11 @@ class StandardCapabilities final : public Capabilities { // |Capabilities| bool SupportsPrimitiveRestart() const override { return true; } + // |Capabilities| + bool SupportsExtendedRangeFormats() const override { + return supports_extended_range_formats_; + } + private: StandardCapabilities(bool supports_offscreen_msaa, bool supports_ssbo, @@ -102,6 +107,7 @@ class StandardCapabilities final : public Capabilities { bool supports_decal_sampler_address_mode, bool supports_device_transient_textures, bool supports_triangle_fan, + bool supports_extended_range_formats, PixelFormat default_color_format, PixelFormat default_stencil_format, PixelFormat default_depth_stencil_format, @@ -118,6 +124,7 @@ class StandardCapabilities final : public Capabilities { supports_decal_sampler_address_mode), supports_device_transient_textures_(supports_device_transient_textures), supports_triangle_fan_(supports_triangle_fan), + supports_extended_range_formats_(supports_extended_range_formats), default_color_format_(default_color_format), default_stencil_format_(default_stencil_format), default_depth_stencil_format_(default_depth_stencil_format), @@ -137,6 +144,7 @@ class StandardCapabilities final : public Capabilities { bool supports_decal_sampler_address_mode_ = false; bool supports_device_transient_textures_ = false; bool supports_triangle_fan_ = false; + bool supports_extended_range_formats_ = false; PixelFormat default_color_format_ = PixelFormat::kUnknown; PixelFormat default_stencil_format_ = PixelFormat::kUnknown; PixelFormat default_depth_stencil_format_ = PixelFormat::kUnknown; @@ -238,6 +246,12 @@ CapabilitiesBuilder& CapabilitiesBuilder::SetMaximumRenderPassAttachmentSize( return *this; } +CapabilitiesBuilder& CapabilitiesBuilder::SetSupportsExtendedRangeFormats( + bool value) { + supports_extended_range_formats_ = value; + return *this; +} + std::unique_ptr CapabilitiesBuilder::Build() { return std::unique_ptr(new StandardCapabilities( // supports_offscreen_msaa_, // @@ -250,6 +264,7 @@ std::unique_ptr CapabilitiesBuilder::Build() { supports_decal_sampler_address_mode_, // supports_device_transient_textures_, // supports_triangle_fan_, // + supports_extended_range_formats_, // default_color_format_.value_or(PixelFormat::kUnknown), // default_stencil_format_.value_or(PixelFormat::kUnknown), // default_depth_stencil_format_.value_or(PixelFormat::kUnknown), // diff --git a/engine/src/flutter/impeller/renderer/capabilities.h b/engine/src/flutter/impeller/renderer/capabilities.h index 1a4d2015516cb..8e2ffc0b728de 100644 --- a/engine/src/flutter/impeller/renderer/capabilities.h +++ b/engine/src/flutter/impeller/renderer/capabilities.h @@ -116,6 +116,13 @@ class Capabilities { /// Note that this may be smaller than the maximum allocatable texture size. virtual ISize GetMaximumRenderPassAttachmentSize() const = 0; + /// @brief Whether the XR formats are supported on this device. + /// + /// This is only ever true for iOS and macOS devices. We may need + /// to revisit this API when approaching wide gamut rendering for + /// Vulkan and GLES. + virtual bool SupportsExtendedRangeFormats() const = 0; + protected: Capabilities(); @@ -154,6 +161,8 @@ class CapabilitiesBuilder { CapabilitiesBuilder& SetSupportsDeviceTransientTextures(bool value); + CapabilitiesBuilder& SetSupportsExtendedRangeFormats(bool value); + CapabilitiesBuilder& SetDefaultGlyphAtlasFormat(PixelFormat value); CapabilitiesBuilder& SetSupportsTriangleFan(bool value); @@ -173,6 +182,7 @@ class CapabilitiesBuilder { bool supports_decal_sampler_address_mode_ = false; bool supports_device_transient_textures_ = false; bool supports_triangle_fan_ = false; + bool supports_extended_range_formats_ = false; std::optional default_color_format_ = std::nullopt; std::optional default_stencil_format_ = std::nullopt; std::optional default_depth_stencil_format_ = std::nullopt; diff --git a/engine/src/flutter/impeller/renderer/capabilities_unittests.cc b/engine/src/flutter/impeller/renderer/capabilities_unittests.cc index f4b83e9dd07a1..e0b59b227fdbb 100644 --- a/engine/src/flutter/impeller/renderer/capabilities_unittests.cc +++ b/engine/src/flutter/impeller/renderer/capabilities_unittests.cc @@ -28,6 +28,7 @@ CAPABILITY_TEST(SupportsReadFromResolve, false); CAPABILITY_TEST(SupportsDecalSamplerAddressMode, false); CAPABILITY_TEST(SupportsDeviceTransientTextures, false); CAPABILITY_TEST(SupportsTriangleFan, false); +CAPABILITY_TEST(SupportsExtendedRangeFormats, false); TEST(CapabilitiesTest, DefaultColorFormat) { auto defaults = CapabilitiesBuilder().Build(); diff --git a/engine/src/flutter/impeller/renderer/testing/mocks.h b/engine/src/flutter/impeller/renderer/testing/mocks.h index 6b4014c030f3b..1b1171a7318da 100644 --- a/engine/src/flutter/impeller/renderer/testing/mocks.h +++ b/engine/src/flutter/impeller/renderer/testing/mocks.h @@ -223,6 +223,7 @@ class MockCapabilities : public Capabilities { MOCK_METHOD(bool, SupportsDeviceTransientTextures, (), (const, override)); MOCK_METHOD(bool, SupportsTriangleFan, (), (const override)); MOCK_METHOD(bool, SupportsPrimitiveRestart, (), (const override)); + MOCK_METHOD(bool, SupportsExtendedRangeFormats, (), (const override)); MOCK_METHOD(PixelFormat, GetDefaultColorFormat, (), (const, override)); MOCK_METHOD(PixelFormat, GetDefaultStencilFormat, (), (const, override)); MOCK_METHOD(PixelFormat, GetDefaultDepthStencilFormat, (), (const, override)); diff --git a/engine/src/flutter/lib/ui/painting/image_decoder_impeller.cc b/engine/src/flutter/lib/ui/painting/image_decoder_impeller.cc index 40b6d9e01c6df..ba6a83e83bbe0 100644 --- a/engine/src/flutter/lib/ui/painting/image_decoder_impeller.cc +++ b/engine/src/flutter/lib/ui/painting/image_decoder_impeller.cc @@ -80,10 +80,10 @@ ImageDecoderImpeller::ImageDecoderImpeller( const TaskRunners& runners, std::shared_ptr concurrent_task_runner, const fml::WeakPtr& io_manager, - bool supports_wide_gamut, + bool wide_gamut_enabled, const std::shared_ptr& gpu_disabled_switch) : ImageDecoder(runners, std::move(concurrent_task_runner), io_manager), - supports_wide_gamut_(supports_wide_gamut), + wide_gamut_enabled_(wide_gamut_enabled), gpu_disabled_switch_(gpu_disabled_switch) { std::promise> context_promise; context_ = context_promise.get_future(); @@ -526,7 +526,7 @@ void ImageDecoderImpeller::Decode(fml::RefPtr descriptor, target_size = SkISize::Make(target_width, target_height), // io_runner = runners_.GetIOTaskRunner(), // result, - supports_wide_gamut = supports_wide_gamut_, // + wide_gamut_enabled = wide_gamut_enabled_, // gpu_disabled_switch = gpu_disabled_switch_]() { #if FML_OS_IOS_SIMULATOR // No-op backend. @@ -545,7 +545,8 @@ void ImageDecoderImpeller::Decode(fml::RefPtr descriptor, // Always decompress on the concurrent runner. auto bitmap_result = DecompressTexture( raw_descriptor, target_size, max_size_supported, - /*supports_wide_gamut=*/supports_wide_gamut, + /*supports_wide_gamut=*/wide_gamut_enabled && + context->GetCapabilities()->SupportsExtendedRangeFormats(), context->GetCapabilities(), context->GetResourceAllocator()); if (!bitmap_result.device_buffer) { result(nullptr, bitmap_result.decode_error); diff --git a/engine/src/flutter/lib/ui/painting/image_decoder_impeller.h b/engine/src/flutter/lib/ui/painting/image_decoder_impeller.h index 61952a218485d..9376193a93e6b 100644 --- a/engine/src/flutter/lib/ui/painting/image_decoder_impeller.h +++ b/engine/src/flutter/lib/ui/painting/image_decoder_impeller.h @@ -101,7 +101,10 @@ class ImageDecoderImpeller final : public ImageDecoder { private: using FutureContext = std::shared_future>; FutureContext context_; - const bool supports_wide_gamut_; + + /// Whether wide gamut rendering has been enabled (but not necessarily whether + /// or not it is supported). + const bool wide_gamut_enabled_; std::shared_ptr gpu_disabled_switch_; /// Only call this method if the GPU is available. From 99d21c80d4748dc9d8be8d2896af5e38ce53718f Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sat, 8 Mar 2025 02:25:10 -0500 Subject: [PATCH 13/21] Roll Skia from b29851b2ada6 to 916caa2f0102 (1 revision) (#164835) https://skia.googlesource.com/skia.git/+log/b29851b2ada6..916caa2f0102 2025-03-07 egdaniel@google.com Revert "Fix need query for copyOnWrite for dual-proxies Ganesh images." If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC codefu@google.com,kjlubick@google.com,michaelludwig@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- engine/src/flutter/ci/licenses_golden/licenses_skia | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DEPS b/DEPS index d29523c04d0d3..d74a218cdff27 100644 --- a/DEPS +++ b/DEPS @@ -14,7 +14,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': 'b29851b2ada6ac6cb3e4fdf218266e47a1a877e6', + 'skia_revision': '916caa2f0102d2f57ad12ff8e2d1b10c7b705928', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. diff --git a/engine/src/flutter/ci/licenses_golden/licenses_skia b/engine/src/flutter/ci/licenses_golden/licenses_skia index 004f3ae4f938f..ff015df821591 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_skia +++ b/engine/src/flutter/ci/licenses_golden/licenses_skia @@ -1,4 +1,4 @@ -Signature: 0bb4f1825069e19201603029e8e24ae0 +Signature: 5382e61287e09b86b90a63bade449181 ==================================================================================================== LIBRARY: etc1 From 95aee5b6b826c43c90fb3e38f195571471773834 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sat, 8 Mar 2025 09:24:30 -0500 Subject: [PATCH 14/21] Roll Fuchsia Linux SDK from ixl5bKWCqsRiYGvps... to 6tAcm4hdtXPE55GJP... (#164838) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/fuchsia-linux-sdk-flutter Please CC codefu@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- engine/src/flutter/ci/licenses_golden/licenses_fuchsia | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DEPS b/DEPS index d74a218cdff27..1f188bea35f69 100644 --- a/DEPS +++ b/DEPS @@ -794,7 +794,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': 'ixl5bKWCqsRiYGvps6dlMUuZU9Geu-ZwVqQ7Bvu41lUC' + 'version': '6tAcm4hdtXPE55GJP4BYDdNlJ49vI8oN9xDJRt42q-sC' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', diff --git a/engine/src/flutter/ci/licenses_golden/licenses_fuchsia b/engine/src/flutter/ci/licenses_golden/licenses_fuchsia index 46f7e51aad59b..5cc7405380a1d 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_fuchsia +++ b/engine/src/flutter/ci/licenses_golden/licenses_fuchsia @@ -1,4 +1,4 @@ -Signature: b9585430c487a6e9f2db389445c83255 +Signature: ee68870102ebb69daa81f2ea72ae880c ==================================================================================================== LIBRARY: fuchsia_sdk From b30fe22b98936097122e3e2cbbb00e1afbb4ab27 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sat, 8 Mar 2025 11:44:26 -0500 Subject: [PATCH 15/21] Roll Skia from 916caa2f0102 to 345dc2d05dcd (1 revision) (#164843) https://skia.googlesource.com/skia.git/+log/916caa2f0102..345dc2d05dcd 2025-03-08 skia-autoroll@skia-public.iam.gserviceaccount.com Manual roll Dawn from ef26b90ad02e to 82fb5f1d2123 (21 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC codefu@google.com,kjlubick@google.com,michaelludwig@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index 1f188bea35f69..f0e791629cda8 100644 --- a/DEPS +++ b/DEPS @@ -14,7 +14,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': '916caa2f0102d2f57ad12ff8e2d1b10c7b705928', + 'skia_revision': '345dc2d05dcdd7f50930d5951d22df4623777a4c', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. From 27788051c4adb22bbd01be87b78c15d5c04d6406 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sun, 9 Mar 2025 10:10:23 -0400 Subject: [PATCH 16/21] Roll Skia from 345dc2d05dcd to 0f53870c7449 (1 revision) (#164865) https://skia.googlesource.com/skia.git/+log/345dc2d05dcd..0f53870c7449 2025-03-09 skia-recreate-skps@skia-swarming-bots.iam.gserviceaccount.com Update SKP version If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC codefu@google.com,danieldilan@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index f0e791629cda8..59e56abf8557a 100644 --- a/DEPS +++ b/DEPS @@ -14,7 +14,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': '345dc2d05dcdd7f50930d5951d22df4623777a4c', + 'skia_revision': '0f53870c7449b732a7ad5e150768a6c09ecd24f6', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. From 93c8ed0775d99230845987fd04026ea481f19e64 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Sun, 9 Mar 2025 12:36:24 -0400 Subject: [PATCH 17/21] Roll Fuchsia Linux SDK from 6tAcm4hdtXPE55GJP... to U-zlyIZrZRbr9I6gv... (#164868) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/fuchsia-linux-sdk-flutter Please CC codefu@google.com,zra@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- engine/src/flutter/ci/licenses_golden/licenses_fuchsia | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/DEPS b/DEPS index 59e56abf8557a..fb36498ef1e55 100644 --- a/DEPS +++ b/DEPS @@ -794,7 +794,7 @@ deps = { 'packages': [ { 'package': 'fuchsia/sdk/core/linux-amd64', - 'version': '6tAcm4hdtXPE55GJP4BYDdNlJ49vI8oN9xDJRt42q-sC' + 'version': 'U-zlyIZrZRbr9I6gvEaRQedzrTwN0CuQqEKYyVZCh3YC' } ], 'condition': 'download_fuchsia_deps and not download_fuchsia_sdk', diff --git a/engine/src/flutter/ci/licenses_golden/licenses_fuchsia b/engine/src/flutter/ci/licenses_golden/licenses_fuchsia index 5cc7405380a1d..cb2ec1990e9dc 100644 --- a/engine/src/flutter/ci/licenses_golden/licenses_fuchsia +++ b/engine/src/flutter/ci/licenses_golden/licenses_fuchsia @@ -1,4 +1,4 @@ -Signature: ee68870102ebb69daa81f2ea72ae880c +Signature: 9ba12b481951cfbd81eedc1f1ecd44e6 ==================================================================================================== LIBRARY: fuchsia_sdk From cb74735412b1f93afc652bcc2ee32a7892c11ec7 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 10 Mar 2025 00:07:15 -0400 Subject: [PATCH 18/21] Roll Skia from 0f53870c7449 to f17d37ee0ac6 (1 revision) (#164887) https://skia.googlesource.com/skia.git/+log/0f53870c7449..f17d37ee0ac6 2025-03-09 skia-autoroll@skia-public.iam.gserviceaccount.com Manual roll ANGLE from 0cdbc7814e59 to 6c2737be88ac (13 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC codefu@google.com,danieldilan@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index fb36498ef1e55..b511410207302 100644 --- a/DEPS +++ b/DEPS @@ -14,7 +14,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': '0f53870c7449b732a7ad5e150768a6c09ecd24f6', + 'skia_revision': 'f17d37ee0ac6f7bd6c9711d622ae3bde063f7e75', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. From 082497087c082e6b4f35eea5fd90636c999df020 Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 10 Mar 2025 03:09:36 -0400 Subject: [PATCH 19/21] Roll Skia from f17d37ee0ac6 to 4ac86f17f2d4 (1 revision) (#164893) https://skia.googlesource.com/skia.git/+log/f17d37ee0ac6..4ac86f17f2d4 2025-03-10 skia-autoroll@skia-public.iam.gserviceaccount.com Roll Dawn from 82fb5f1d2123 to a04b51ef7139 (3 revisions) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/skia-flutter-autoroll Please CC codefu@google.com,danieldilan@google.com,kjlubick@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Skia: https://bugs.chromium.org/p/skia/issues/entry To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- DEPS | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/DEPS b/DEPS index b511410207302..3f3ead923bc9d 100644 --- a/DEPS +++ b/DEPS @@ -14,7 +14,7 @@ vars = { 'flutter_git': 'https://flutter.googlesource.com', 'skia_git': 'https://skia.googlesource.com', 'llvm_git': 'https://llvm.googlesource.com', - 'skia_revision': 'f17d37ee0ac6f7bd6c9711d622ae3bde063f7e75', + 'skia_revision': '4ac86f17f2d4bf8a892c1ff42f69cfb2310a0f6c', # WARNING: DO NOT EDIT canvaskit_cipd_instance MANUALLY # See `lib/web_ui/README.md` for how to roll CanvasKit to a new version. From 43ca28b8e81c07d757a121cff7edbcec63e7d64c Mon Sep 17 00:00:00 2001 From: engine-flutter-autoroll Date: Mon, 10 Mar 2025 11:56:30 -0400 Subject: [PATCH 20/21] Roll Packages from 4c5a7ed11ea1 to 464cea53002e (5 revisions) (#164904) https://github.com/flutter/packages/compare/4c5a7ed11ea1...464cea53002e 2025-03-10 robert.odrowaz@leancode.pl [camera_avfoundation] Tests backfilling - part 2 (flutter/packages#8796) 2025-03-08 neilself@gmail.com [google_sign_in] Add Android account name field as optional (flutter/packages#8573) 2025-03-07 engine-flutter-autoroll@skia.org Roll Flutter from 321fbc0e7e81 to 6b93cf93c100 (18 revisions) (flutter/packages#8817) 2025-03-07 engine-flutter-autoroll@skia.org Roll Flutter (stable) from 68415ad1d920 to 09de023485e9 (1139 revisions) (flutter/packages#8813) 2025-03-07 veronika@resolutionapp.co.nz [google_maps_flutter_web] set icon anchor for markers (flutter/packages#8077) If this roll has caused a breakage, revert this CL and stop the roller using the controls here: https://autoroll.skia.org/r/flutter-packages-flutter-autoroll Please CC flutter-ecosystem@google.com on the revert to ensure that a human is aware of the problem. To file a bug in Flutter: https://github.com/flutter/flutter/issues/new/choose To report a problem with the AutoRoller itself, please file a bug: https://issues.skia.org/issues/new?component=1389291&template=1850622 Documentation for the AutoRoller is here: https://skia.googlesource.com/buildbot/+doc/main/autoroll/README.md --- bin/internal/flutter_packages.version | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bin/internal/flutter_packages.version b/bin/internal/flutter_packages.version index 6b00965484130..132e0e1451979 100644 --- a/bin/internal/flutter_packages.version +++ b/bin/internal/flutter_packages.version @@ -1 +1 @@ -4c5a7ed11ea119f411079373d44bf2a839ef4833 +464cea53002ebdaa740616f1dd68a1cf2dc726fb From b16430b2fd57a5aa6b6c680cdbda5582506fe120 Mon Sep 17 00:00:00 2001 From: Jonah Williams Date: Mon, 10 Mar 2025 09:19:21 -0700 Subject: [PATCH 21/21] [macOS] Enable Impeller by default on macOS. (#164572) Enables impeller by default on macOS devices. An opt out can still be configured by passing --no-enable-impeller or using the FLTEnableImpeller / NO setting in the Info.plist. --- .../darwin/macos/framework/Source/FlutterDartProject.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterDartProject.mm b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterDartProject.mm index 7d1c06122f34e..bda48a2299eaa 100644 --- a/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterDartProject.mm +++ b/engine/src/flutter/shell/platform/darwin/macos/framework/Source/FlutterDartProject.mm @@ -65,7 +65,7 @@ - (BOOL)enableImpeller { if (enableImpeller != nil) { return enableImpeller.boolValue; } - return NO; + return YES; } - (NSString*)assetsPath { pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy