diff --git a/CHANGELOG.md b/CHANGELOG.md index 0afa524909..a868dce306 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ * Controls in Python are now defined as plain dataclasses * Unified diffing algorithm supports both imperative and declarative styles * Refactored Flutter layer using inherited widgets and `Provider` +* Added a Shimmer control for building skeleton loaders and animated placeholders. * Added `FletApp.appErrorMessage` template to customize loading screen errors. * See the list of [breaking changes](https://github.com/flet-dev/flet/issues/5238) diff --git a/client/pubspec.lock b/client/pubspec.lock index 6f8197566b..bb5a70eb2b 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -1190,6 +1190,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shimmer: + dependency: transitive + description: + name: shimmer + sha256: "5f88c883a22e9f9f299e5ba0e4f7e6054857224976a5d9f839d4ebdc94a14ac9" + url: "https://pub.dev" + source: hosted + version: "3.0.0" sky_engine: dependency: transitive description: flutter diff --git a/packages/flet/lib/src/controls/semantics.dart b/packages/flet/lib/src/controls/semantics.dart index 42803ad604..eb93e20657 100644 --- a/packages/flet/lib/src/controls/semantics.dart +++ b/packages/flet/lib/src/controls/semantics.dart @@ -31,7 +31,7 @@ class SemanticsControl extends StatelessWidget { hint: control.getString("hint"), onTapHint: control.getString("on_tap_hint"), onLongPressHint: control.getString("on_long_press_hint"), - container: control.getBool("container")!, + container: control.getBool("container", false)!, liveRegion: control.getBool("live_region"), obscured: control.getBool("obscured"), multiline: control.getBool("multiline"), diff --git a/packages/flet/lib/src/controls/shimmer.dart b/packages/flet/lib/src/controls/shimmer.dart new file mode 100644 index 0000000000..a5c78615de --- /dev/null +++ b/packages/flet/lib/src/controls/shimmer.dart @@ -0,0 +1,73 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:shimmer/shimmer.dart'; + +import '../extensions/control.dart'; +import '../models/control.dart'; +import '../utils/colors.dart'; +import '../utils/gradient.dart'; +import '../utils/numbers.dart'; +import '../utils/time.dart'; +import '../widgets/error.dart'; +import 'base_controls.dart'; + +class ShimmerControl extends StatelessWidget { + final Control control; + + const ShimmerControl({super.key, required this.control}); + + @override + Widget build(BuildContext context) { + debugPrint("Shimmer build: ${control.id}"); + + final content = control.buildWidget("content"); + if (content == null) { + return const ErrorControl("Shimmer.content must be specified"); + } + + final gradient = control.getGradient("gradient", Theme.of(context)); + final baseColor = control.getColor("base_color", context); + final highlightColor = control.getColor("highlight_color", context); + + if (gradient == null && (baseColor == null || highlightColor == null)) { + return const ErrorControl( + "Shimmer requires either gradient or base/highlight colors"); + } + + final direction = _parseDirection(control.getString("direction")); + final period = + control.getDuration("period", const Duration(milliseconds: 1500))!; + final loop = control.getInt("loop", 0)!; + + final shimmerWidget = gradient != null + ? Shimmer( + gradient: gradient, + direction: direction, + period: period, + loop: loop, + enabled: !control.disabled, + child: content, + ) + : Shimmer.fromColors( + baseColor: baseColor!, + highlightColor: highlightColor!, + direction: direction, + period: period, + loop: loop, + enabled: !control.disabled, + child: content, + ); + + return LayoutControl(control: control, child: shimmerWidget); + } +} + +ShimmerDirection _parseDirection(String? value, + [ShimmerDirection defaultValue = ShimmerDirection.ltr]) { + if (value == null) { + return defaultValue; + } + return ShimmerDirection.values.firstWhereOrNull( + (dir) => dir.name.toLowerCase() == value.toLowerCase()) ?? + defaultValue; +} diff --git a/packages/flet/lib/src/flet_core_extension.dart b/packages/flet/lib/src/flet_core_extension.dart index 17e0868ff5..8b96de98f0 100644 --- a/packages/flet/lib/src/flet_core_extension.dart +++ b/packages/flet/lib/src/flet_core_extension.dart @@ -92,6 +92,7 @@ import 'controls/segmented_button.dart'; import 'controls/selection_area.dart'; import 'controls/semantics.dart'; import 'controls/shader_mask.dart'; +import 'controls/shimmer.dart'; import 'controls/snack_bar.dart'; import 'controls/stack.dart'; import 'controls/submenu_button.dart'; @@ -325,6 +326,8 @@ class FletCoreExtension extends FletExtension { return SemanticsControl(key: key, control: control); case "ShaderMask": return ShaderMaskControl(key: key, control: control); + case "Shimmer": + return ShimmerControl(key: key, control: control); case "Slider": return AdaptiveSliderControl(key: key, control: control); case "SnackBar": diff --git a/packages/flet/pubspec.yaml b/packages/flet/pubspec.yaml index 7caf4f1b3d..996cb72e31 100644 --- a/packages/flet/pubspec.yaml +++ b/packages/flet/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: screenshot: ^3.0.0 sensors_plus: ^6.1.1 shared_preferences: 2.5.3 + shimmer: ^3.0.0 url_launcher: 6.3.2 vector_math: ^2.2.0 web: ^1.1.1 diff --git a/sdk/python/examples/controls/file_picker/pick_and_upload.py b/sdk/python/examples/controls/file_picker/pick_and_upload.py index 30bd725a43..a90ca6df2d 100644 --- a/sdk/python/examples/controls/file_picker/pick_and_upload.py +++ b/sdk/python/examples/controls/file_picker/pick_and_upload.py @@ -20,6 +20,21 @@ class State: def main(page: ft.Page): + if not page.web: + page.add( + ft.Text( + "This example is only available in Flet Web mode.\n" + "\n" + "Run this example with:\n" + " export FLET_SECRET_KEY=\n" + " flet run --web " + "examples/controls/file_picker/pick_and_upload.py", + color=ft.Colors.RED, + selectable=True, + ) + ) + return + prog_bars: dict[str, ft.ProgressRing] = {} def on_upload_progress(e: ft.FilePickerUploadEvent): diff --git a/sdk/python/examples/controls/haptic_feedback/basic.py b/sdk/python/examples/controls/haptic_feedback/basic.py index 8240ba2970..158586aac9 100644 --- a/sdk/python/examples/controls/haptic_feedback/basic.py +++ b/sdk/python/examples/controls/haptic_feedback/basic.py @@ -2,7 +2,7 @@ def main(page: ft.Page): - page.overlay.append(hf := ft.HapticFeedback()) + hf = ft.HapticFeedback() async def heavy_impact(): await hf.heavy_impact() diff --git a/sdk/python/examples/controls/shake_detector/basic.py b/sdk/python/examples/controls/shake_detector/basic.py index 1623b76e8c..09d0a63c89 100644 --- a/sdk/python/examples/controls/shake_detector/basic.py +++ b/sdk/python/examples/controls/shake_detector/basic.py @@ -2,15 +2,13 @@ def main(page: ft.Page): - # just need hold a reference to ShakeDetector in the session store - page.session.store.set( - "shake_detector", + page.services.append( ft.ShakeDetector( minimum_shake_count=2, shake_slop_time_ms=300, shake_count_reset_time_ms=1000, on_shake=lambda _: page.add(ft.Text("Shake detected!")), - ), + ) ) page.add(ft.Text("Shake your device!")) diff --git a/sdk/python/examples/controls/shimmer/__init__.py b/sdk/python/examples/controls/shimmer/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/sdk/python/examples/controls/shimmer/basic.py b/sdk/python/examples/controls/shimmer/basic.py new file mode 100644 index 0000000000..77b6a49f23 --- /dev/null +++ b/sdk/python/examples/controls/shimmer/basic.py @@ -0,0 +1,21 @@ +import flet as ft + + +def main(page: ft.Page): + page.add( + ft.Shimmer( + base_color=ft.Colors.with_opacity(0.3, ft.Colors.GREY_400), + highlight_color=ft.Colors.WHITE, + content=ft.Column( + controls=[ + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ], + ), + ) + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/controls/shimmer/basic_placeholder.py b/sdk/python/examples/controls/shimmer/basic_placeholder.py new file mode 100644 index 0000000000..ed491f8653 --- /dev/null +++ b/sdk/python/examples/controls/shimmer/basic_placeholder.py @@ -0,0 +1,68 @@ +import flet as ft + + +def _line(width: int, height: int = 12) -> ft.Control: + return ft.Container( + width=width, + height=height, + bgcolor=ft.Colors.GREY_400, + border_radius=ft.BorderRadius.all(height), + ) + + +def _placeholder_tile() -> ft.Control: + return ft.Container( + padding=ft.Padding.all(16), + bgcolor=ft.Colors.with_opacity(0.3, ft.Colors.WHITE), + border_radius=ft.BorderRadius.all(20), + content=ft.Row( + spacing=16, + vertical_alignment=ft.CrossAxisAlignment.START, + controls=[ + ft.Container( + width=48, + height=48, + bgcolor=ft.Colors.with_opacity(0.5, ft.Colors.GREY_400), + border_radius=ft.BorderRadius.all(24), + content=ft.Icon(ft.Icons.PERSON, color=ft.Colors.GREY_500), + ), + ft.Column( + expand=True, + spacing=10, + controls=[ + _line(160), + _line(120), + ft.Row( + spacing=10, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + controls=[_line(70, 10), _line(90, 10)], + ), + ], + ), + ft.Container( + width=32, + height=32, + bgcolor=ft.Colors.GREY_200, + border_radius=ft.BorderRadius.all(16), + ), + ], + ), + ) + + +def main(page: ft.Page): + page.title = "Shimmer - loading placeholders" + + page.add( + ft.Shimmer( + base_color=ft.Colors.with_opacity(0.3, ft.Colors.GREY_400), + highlight_color=ft.Colors.WHITE, + content=ft.Column( + controls=[_placeholder_tile() for _ in range(3)], + ), + ), + ) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/controls/shimmer/custom_gradient.py b/sdk/python/examples/controls/shimmer/custom_gradient.py new file mode 100644 index 0000000000..13f22c6992 --- /dev/null +++ b/sdk/python/examples/controls/shimmer/custom_gradient.py @@ -0,0 +1,69 @@ +import flet as ft + + +def _stat_block(title: str, subtitle: str) -> ft.Control: + def metric(width: int, height: int = 14) -> ft.Control: + return ft.Container( + width=width, + height=height, + bgcolor=ft.Colors.WHITE, + opacity=0.6, + border_radius=ft.BorderRadius.all(height), + ) + + return ft.Container( + width=200, + padding=ft.Padding.all(20), + bgcolor=ft.Colors.with_opacity(0.1, ft.Colors.BLACK), + border_radius=ft.BorderRadius.all(24), + content=ft.Column( + spacing=16, + controls=[ + metric(140), + ft.Row(spacing=10, controls=[metric(60, 10), metric(90, 10)]), + ft.Container( + border_radius=ft.BorderRadius.all(16), + bgcolor=ft.Colors.WHITE, + opacity=0.35, + ), + ft.Column(spacing=8, controls=[metric(120, 12), metric(160, 12)]), + ft.Text(title, weight=ft.FontWeight.W_600), + ft.Text(subtitle, size=12), + ], + ), + ) + + +def main(page: ft.Page): + page.title = "Shimmer - custom gradients" + page.bgcolor = "#0e0e18" + accent = ft.LinearGradient( + begin=ft.Alignment(-1.0, -0.5), + end=ft.Alignment(1.0, 0.5), + colors=[ + ft.Colors.PURPLE, + ft.Colors.PURPLE, + ft.Colors.AMBER_200, + ft.Colors.PURPLE, + ft.Colors.PURPLE, + ], + stops=[0.0, 0.35, 0.5, 0.65, 1.0], + ) + + cards = ft.Row( + wrap=True, + controls=[ + ft.Shimmer( + gradient=accent, + direction=ft.ShimmerDirection.TTB, + period=2200, + content=_stat_block("Recent activity", "Smooth top-to-bottom sweep"), + ), + ], + ) + + page.add(cards) + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/packages/flet/docs/controls/shimmer.md b/sdk/python/packages/flet/docs/controls/shimmer.md new file mode 100644 index 0000000000..b828222001 --- /dev/null +++ b/sdk/python/packages/flet/docs/controls/shimmer.md @@ -0,0 +1,35 @@ +--- +class_name: flet.Shimmer +examples: ../../examples/controls/shimmer +example_images: ../test-images/examples/core/golden/macos/shimmer +--- + +{{ class_summary(class_name, example_images + "/image_for_docs.gif", image_caption="Basic shimmer") }} + +## Examples + +### Basic + +```python +--8<-- "{{ examples }}/basic.py" +``` + +{{ image(example_images + "/image_for_docs.gif", alt="custom-label", width="50%") }} + +### Skeleton list placeholders + +```python +--8<-- "{{ examples }}/basic_placeholder.py" +``` + +{{ image(example_images + "/basic_placeholder.png", alt="custom-label", width="50%") }} + +### Custom gradients and directions + +```python +--8<-- "{{ examples }}/custom_gradient.py" +``` + +{{ image(example_images + "/custom_gradient.png", alt="custom-label", width="50%") }} + +{{ class_members(class_name) }} diff --git a/sdk/python/packages/flet/integration_tests/conftest.py b/sdk/python/packages/flet/integration_tests/conftest.py index 6091ec977b..608d26608b 100644 --- a/sdk/python/packages/flet/integration_tests/conftest.py +++ b/sdk/python/packages/flet/integration_tests/conftest.py @@ -11,6 +11,7 @@ def create_flet_app(request): flutter_app_dir=(Path(__file__).parent / "../../../../../client").resolve(), test_path=request.fspath, flet_app_main=params.get("flet_app_main"), + skip_pump_and_settle=params.get("skip_pump_and_settle", False), assets_dir=Path(__file__).resolve().parent / "assets", ) diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/basic_placeholder.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/basic_placeholder.png new file mode 100644 index 0000000000..55c7d0cc93 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/basic_placeholder.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/custom_gradient.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/custom_gradient.png new file mode 100644 index 0000000000..55363ccb97 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/custom_gradient.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs.gif b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs.gif new file mode 100644 index 0000000000..587b3d77af Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs.gif differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_0.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_0.png new file mode 100644 index 0000000000..005fba4011 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_0.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_1.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_1.png new file mode 100644 index 0000000000..c5d3436457 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_1.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_2.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_2.png new file mode 100644 index 0000000000..8ad53b2259 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_2.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_3.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_3.png new file mode 100644 index 0000000000..c22aee30e1 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_3.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_4.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_4.png new file mode 100644 index 0000000000..9c835a26cd Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_4.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_5.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_5.png new file mode 100644 index 0000000000..a898c3154b Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_5.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_6.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_6.png new file mode 100644 index 0000000000..fc96c921fb Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_6.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_7.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_7.png new file mode 100644 index 0000000000..983f38ef94 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_7.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_8.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_8.png new file mode 100644 index 0000000000..b4a66362af Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_8.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_9.png b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_9.png new file mode 100644 index 0000000000..6d75899bc1 Binary files /dev/null and b/sdk/python/packages/flet/integration_tests/examples/core/golden/macos/shimmer/image_for_docs_9.png differ diff --git a/sdk/python/packages/flet/integration_tests/examples/core/test_shimmer.py b/sdk/python/packages/flet/integration_tests/examples/core/test_shimmer.py new file mode 100644 index 0000000000..b4e7158ee9 --- /dev/null +++ b/sdk/python/packages/flet/integration_tests/examples/core/test_shimmer.py @@ -0,0 +1,103 @@ +import pytest + +import flet as ft +import flet.testing as ftt +from examples.controls.shimmer import basic_placeholder, custom_gradient + + +@pytest.mark.asyncio(loop_scope="function") +async def test_image_for_docs(flet_app_function: ftt.FletTestApp, request): + page = flet_app_function.page + page.enable_screenshots = True + flet_app_function.resize_page(350, 280) + page.update() + await flet_app_function.tester.pump_and_settle() + page.add( + ft.Shimmer( + base_color=ft.Colors.with_opacity(0.3, ft.Colors.GREY_400), + highlight_color=ft.Colors.WHITE, + content=ft.Column( + controls=[ + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ], + ), + ) + ) + + images = [] + for counter in range(10): + await flet_app_function.tester.pump(100) + name = f"image_for_docs_{counter}" + images.append(name) + flet_app_function.assert_screenshot( + name, + await flet_app_function.page.take_screenshot( + pixel_ratio=flet_app_function.screenshots_pixel_ratio + ), + ) + + flet_app_function.create_gif( + image_names=images, output_name="image_for_docs", duration=200 + ) + + +@pytest.mark.parametrize( + "flet_app_function", + [ + { + "flet_app_main": basic_placeholder.main, + "skip_pump_and_settle": True, + } + ], + indirect=True, +) +@pytest.mark.asyncio(loop_scope="function") +async def test_basic_placeholder(flet_app_function: ftt.FletTestApp): + flet_app_function.page.enable_screenshots = True + flet_app_function.page.controls[0].disabled = True + flet_app_function.resize_page(500, 300) + flet_app_function.page.update() + await flet_app_function.tester.pump_and_settle() + + flet_app_function.page.controls[0].disabled = False + flet_app_function.page.update() + for _ in range(5): + await flet_app_function.tester.pump(100) + flet_app_function.assert_screenshot( + "basic_placeholder", + await flet_app_function.page.take_screenshot( + pixel_ratio=flet_app_function.screenshots_pixel_ratio + ), + ) + + +@pytest.mark.parametrize( + "flet_app_function", + [ + { + "flet_app_main": custom_gradient.main, + "skip_pump_and_settle": True, + } + ], + indirect=True, +) +@pytest.mark.asyncio(loop_scope="function") +async def test_custom_gradient(flet_app_function: ftt.FletTestApp): + flet_app_function.page.enable_screenshots = True + flet_app_function.page.controls[0].disabled = True + flet_app_function.resize_page(220, 300) + flet_app_function.page.update() + await flet_app_function.tester.pump_and_settle() + + flet_app_function.page.controls[0].disabled = False + flet_app_function.page.update() + for _ in range(8): + await flet_app_function.tester.pump(100) + flet_app_function.assert_screenshot( + "custom_gradient", + await flet_app_function.page.take_screenshot( + pixel_ratio=flet_app_function.screenshots_pixel_ratio + ), + ) diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index ee2f3985c9..ce2b44f071 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -435,6 +435,7 @@ nav: - Semantics: controls/semantics.md - SemanticsService: controls/semanticsservice.md - ShaderMask: controls/shadermask.md + - Shimmer: controls/shimmer.md - ShakeDetector: controls/shakedetector.md - Screenshot: controls/screenshot.md - Slider: controls/slider.md diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index e2c8c87976..e86c4e66f0 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -130,6 +130,7 @@ from flet.controls.core.screenshot import Screenshot from flet.controls.core.semantics import Semantics from flet.controls.core.shader_mask import ShaderMask +from flet.controls.core.shimmer import Shimmer, ShimmerDirection from flet.controls.core.stack import Stack, StackFit from flet.controls.core.text import ( Text, @@ -892,6 +893,8 @@ "ShakeDetector", "ShapeBorder", "SharedPreferences", + "Shimmer", + "ShimmerDirection", "Size", "Slider", "SliderInteraction", diff --git a/sdk/python/packages/flet/src/flet/controls/base_page.py b/sdk/python/packages/flet/src/flet/controls/base_page.py index 2136fd9be8..91c889e8bd 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_page.py +++ b/sdk/python/packages/flet/src/flet/controls/base_page.py @@ -26,6 +26,7 @@ from flet.controls.material.navigation_bar import NavigationBar from flet.controls.material.navigation_drawer import NavigationDrawer from flet.controls.padding import Padding, PaddingValue +from flet.controls.services.service import Service from flet.controls.theme import Theme from flet.controls.transform import OffsetValue from flet.controls.types import ( @@ -252,6 +253,7 @@ def handle_page_size(e): [`Page.window`][flet.Page.window] instead. """ + services: list[Service] = field(default_factory=list, metadata={"skip": True}) _overlay: "Overlay" = field(default_factory=lambda: Overlay()) _dialogs: "Dialogs" = field(default_factory=lambda: Dialogs()) diff --git a/sdk/python/packages/flet/src/flet/controls/core/shimmer.py b/sdk/python/packages/flet/src/flet/controls/core/shimmer.py new file mode 100644 index 0000000000..38e9ab2429 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/controls/core/shimmer.py @@ -0,0 +1,109 @@ +from enum import Enum +from typing import Optional + +from flet.controls.base_control import control +from flet.controls.control import Control +from flet.controls.duration import DurationValue +from flet.controls.gradients import Gradient +from flet.controls.layout_control import LayoutControl +from flet.controls.types import ( + ColorValue, +) + +__all__ = ["Shimmer", "ShimmerDirection"] + + +class ShimmerDirection(Enum): + """ + Direction of the shimmering gradient animation. + """ + + LTR = "ltr" + """ + The shimmer moves from left to right. + """ + + RTL = "rtl" + """ + The shimmer moves from right to left. + """ + + TTB = "ttb" + """ + The shimmer moves from top to bottom. + """ + + BTT = "btt" + """ + The shimmer moves from bottom to top. + """ + + +@control("Shimmer") +class Shimmer(LayoutControl): + """ + Applies an animated shimmering effect to its [`content`][(c).]. + + Use it to create lightweight loading placeholders or to add motion to + otherwise static layouts. + + ```python + ft.Shimmer( + base_color=ft.Colors.with_opacity(0.3, ft.Colors.GREY_400), + highlight_color=ft.Colors.WHITE, + content=ft.Column( + controls=[ + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ft.Container(height=80, bgcolor=ft.Colors.GREY_300), + ], + ), + ) + ``` + """ + + content: Control + """ + The control to render with the shimmer effect. + """ + + gradient: Optional[Gradient] = None + """ + Custom gradient that defines the shimmer colors. + """ + + base_color: Optional[ColorValue] = None + """ + Base color used when no [`gradient`][(c).] is provided. + """ + + highlight_color: Optional[ColorValue] = None + """ + Highlight color used when no [`gradient`][(c).] is provided. + """ + + period: DurationValue = 1500 + """ + Duration of a shimmer cycle in milliseconds. + """ + + direction: ShimmerDirection = ShimmerDirection.LTR + """ + Direction of the shimmering animation. + """ + + loop: int = 0 + """ + Number of times the animation should repeat. `0` means infinite. + """ + + def before_update(self): + super().before_update() + if self.content is None: + raise ValueError("content must be provided.") + if self.gradient is None and ( + self.base_color is None or self.highlight_color is None + ): + raise ValueError("Either gradient or both base_color and highlight_color must be provided.") + if self.loop is not None and self.loop < 0: + raise ValueError("loop must be a non-negative integer (greater than or equal to 0).") diff --git a/sdk/python/packages/flet/src/flet/testing/flet_test_app.py b/sdk/python/packages/flet/src/flet/testing/flet_test_app.py index 84404eae3d..b20e9c7744 100644 --- a/sdk/python/packages/flet/src/flet/testing/flet_test_app.py +++ b/sdk/python/packages/flet/src/flet/testing/flet_test_app.py @@ -80,6 +80,9 @@ class FletTestApp: If `True`, do not invoke `fvm` when running the Flutter test process. Env override: `FLET_TEST_DISABLE_FVM=1`. + skip_pump_and_settle: + If `True`, the initial `pump_and_settle` after app start is skipped. + Environment Variables: - `FLET_TEST_PLATFORM`: Overrides `test_platform`. - `FLET_TEST_DEVICE`: Overrides `test_device`. @@ -105,6 +108,7 @@ def __init__( screenshots_similarity_threshold: float = 99.0, use_http: bool = False, disable_fvm: bool = False, + skip_pump_and_settle: bool = False, ): self.test_platform = os.getenv("FLET_TEST_PLATFORM", test_platform) self.test_device = os.getenv("FLET_TEST_DEVICE", test_device) @@ -124,12 +128,13 @@ def __init__( self.__use_http = get_bool_env_var("FLET_TEST_USE_HTTP") or use_http self.__test_path = test_path self.__flet_app_main = flet_app_main + self.__skip_pump_and_settle = skip_pump_and_settle self.__flutter_app_dir = flutter_app_dir self.__assets_dir = assets_dir or "assets" self.__tcp_port = tcp_port self.__flutter_process: Optional[asyncio.subprocess.Process] = None self.__page = None - self.__tester = None + self.__tester: Tester | None = None @property def page(self) -> ft.Page: @@ -167,7 +172,8 @@ async def main(page: ft.Page): await self.__flet_app_main(page) elif callable(self.__flet_app_main): self.__flet_app_main(page) - await self.__tester.pump_and_settle() + if not self.__skip_pump_and_settle: + await self.__tester.pump_and_settle() ready.set() if not self.__tcp_port: @@ -464,7 +470,16 @@ def create_gif( path = golden_dir / f"{name}.png" if not path.exists(): raise FileNotFoundError(path) - frames.append(Image.open(path)) + + frames.append( + Image.open(path) + .convert("RGB") + .convert( + "P", + palette=Image.ADAPTIVE, + colors=256, + ) + ) first, *rest = frames first.save( @@ -473,6 +488,7 @@ def create_gif( append_images=rest, duration=duration, loop=loop, + optimize=True, ) finally: for frame in frames: