diff --git a/CHANGELOG.md b/CHANGELOG.md index a9501af990..138a25c99a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ * Add `ft.use_dialog()` hook for declarative dialog management from within `@ft.component` functions, with frozen-diff reactive updates and automatic open/close lifecycle ([#6335](https://github.com/flet-dev/flet/pull/6335)) by @FeodorFitsner. * Add `scrollable`, `pin_leading_to_top`, and `pin_trailing_to_bottom` properties to `NavigationRail` for scrollable content with optional pinned leading/trailing controls ([#1923](https://github.com/flet-dev/flet/issues/1923), [#6356](https://github.com/flet-dev/flet/pull/6356)) by @ndonkoHenri. +* Add `Page.pop_views_until()` to pop multiple views and return a result to the destination view ([#6326](https://github.com/flet-dev/flet/issues/6326), [#6347](https://github.com/flet-dev/flet/pull/6347)) by @brunobrown. ### Improvements diff --git a/client/pubspec.lock b/client/pubspec.lock index f865fa37eb..6292be3ed7 100644 --- a/client/pubspec.lock +++ b/client/pubspec.lock @@ -157,10 +157,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -359,7 +359,7 @@ packages: path: "../packages/flet" relative: true source: path - version: "0.85.0" + version: "0.82.2" flet_ads: dependency: "direct main" description: @@ -911,18 +911,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" media_kit: dependency: transitive description: @@ -1628,10 +1628,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" torch_light: dependency: transitive description: diff --git a/sdk/python/examples/apps/routing_navigation/pop_views_until/main.py b/sdk/python/examples/apps/routing_navigation/pop_views_until/main.py new file mode 100644 index 0000000000..a4251c14e2 --- /dev/null +++ b/sdk/python/examples/apps/routing_navigation/pop_views_until/main.py @@ -0,0 +1,112 @@ +import asyncio + +import flet as ft + + +def main(page: ft.Page): + page.title = "Routes Example" + + result_text = ft.Text("No result yet", size=18) + + def route_change(): + page.views.clear() + + # Home View (/) + page.views.append( + ft.View( + route="/", + controls=[ + ft.AppBar(title=ft.Text("Home"), bgcolor=ft.Colors.SURFACE_BRIGHT), + result_text, + ft.Button( + "Start flow", + on_click=lambda _: asyncio.create_task( + page.push_route("/step1") + ), + ), + ], + ) + ) + + if page.route == "/step1" or page.route == "/step2" or page.route == "/step3": + page.views.append( + ft.View( + route="/step1", + controls=[ + ft.AppBar( + title=ft.Text("Step 1"), + bgcolor=ft.Colors.SURFACE_BRIGHT, + ), + ft.Text("Step 1 of the flow"), + ft.Button( + "Go to Step 2", + on_click=lambda _: asyncio.create_task( + page.push_route("/step2") + ), + ), + ], + ) + ) + + if page.route == "/step2" or page.route == "/step3": + page.views.append( + ft.View( + route="/step2", + controls=[ + ft.AppBar( + title=ft.Text("Step 2"), + bgcolor=ft.Colors.SURFACE_BRIGHT, + ), + ft.Text("Step 2 of the flow"), + ft.Button( + "Go to Step 3", + on_click=lambda _: asyncio.create_task( + page.push_route("/step3") + ), + ), + ], + ) + ) + + if page.route == "/step3": + page.views.append( + ft.View( + route="/step3", + controls=[ + ft.AppBar( + title=ft.Text("Step 3 (Final)"), + bgcolor=ft.Colors.SURFACE_BRIGHT, + ), + ft.Text("Flow complete!"), + ft.Button( + "Finish and go Home", + on_click=lambda _: asyncio.create_task( + page.pop_views_until("/", result="Flow completed!") + ), + ), + ], + ) + ) + + page.update() + + def on_pop_result(e: ft.ViewsPopUntilEvent): + result_text.value = f"Result: {e.result}" + page.show_dialog(ft.SnackBar(ft.Text(f"Got result: {e.result}"))) + page.update() + + async def view_pop(e: ft.ViewPopEvent): + if e.view is not None: + page.views.remove(e.view) + top_view = page.views[-1] + await page.push_route(top_view.route) + + page.on_route_change = route_change + page.on_view_pop = view_pop + page.on_views_pop_until = on_pop_result + + route_change() + + +if __name__ == "__main__": + ft.run(main) diff --git a/sdk/python/examples/apps/routing_navigation/pop_views_until/pyproject.toml b/sdk/python/examples/apps/routing_navigation/pop_views_until/pyproject.toml new file mode 100644 index 0000000000..b665a71f29 --- /dev/null +++ b/sdk/python/examples/apps/routing_navigation/pop_views_until/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "apps-routing-navigation-pop-views-until" +version = "1.0.0" +description = "Pops multiple views from the navigation stack and returns a result to the destination view." +requires-python = ">=3.10" +keywords = ["apps", "routing", "navigation", "pop", "views", "result", "async"] +authors = [{ name = "Flet team", email = "hello@flet.dev" }] +dependencies = ["flet"] + +[dependency-groups] +dev = ["flet-cli", "flet-desktop", "flet-web"] + +[tool.flet.gallery] +categories = ["Apps/Navigation"] + +[tool.flet.metadata] +title = "Pop views until" +controls = ["View", "AppBar", "Button", "Text", "SnackBar"] +layout_pattern = "multi-step-flow" +complexity = "intermediate" +features = ["routing", "view stack", "pop views until", "result passing", "async"] + +[tool.flet] +org = "dev.flet" +company = "Flet" +copyright = "Copyright (C) 2023-2026 by Flet" diff --git a/sdk/python/packages/flet/integration_tests/examples/apps/test_routing_navigation.py b/sdk/python/packages/flet/integration_tests/examples/apps/test_routing_navigation.py index 8857ef302a..824d8394c7 100644 --- a/sdk/python/packages/flet/integration_tests/examples/apps/test_routing_navigation.py +++ b/sdk/python/packages/flet/integration_tests/examples/apps/test_routing_navigation.py @@ -6,6 +6,7 @@ from examples.apps.routing_navigation.home_store import main as home_store from examples.apps.routing_navigation.initial_route import main as initial_route from examples.apps.routing_navigation.pop_view_confirm import main as pop_view_confirm +from examples.apps.routing_navigation.pop_views_until import main as pop_views_until from examples.apps.routing_navigation.route_change_event import ( main as route_change_event, ) @@ -185,3 +186,59 @@ async def test_drawer_navigation(flet_app_function: ftt.FletTestApp): # Verify home view home_text = await flet_app_function.tester.find_by_text("Welcome to Home Page") assert home_text.count == 1 + + +@pytest.mark.parametrize( + "flet_app_function", + [{"flet_app_main": pop_views_until.main}], + indirect=True, +) +@pytest.mark.asyncio(loop_scope="function") +async def test_pop_views_until(flet_app_function: ftt.FletTestApp): + # Verify initial view + button = await flet_app_function.tester.find_by_text_containing("Start flow") + assert button.count == 1 + result_text = await flet_app_function.tester.find_by_text("No result yet") + assert result_text.count == 1 + + # Navigate to Step 1 + await flet_app_function.tester.tap(button) + await flet_app_function.tester.pump_and_settle() + step1_text = await flet_app_function.tester.find_by_text("Step 1 of the flow") + assert step1_text.count == 1 + + # Navigate to Step 2 + step2_button = await flet_app_function.tester.find_by_text_containing( + "Go to Step 2" + ) + assert step2_button.count == 1 + await flet_app_function.tester.tap(step2_button) + await flet_app_function.tester.pump_and_settle() + step2_text = await flet_app_function.tester.find_by_text("Step 2 of the flow") + assert step2_text.count == 1 + + # Navigate to Step 3 + step3_button = await flet_app_function.tester.find_by_text_containing( + "Go to Step 3" + ) + assert step3_button.count == 1 + await flet_app_function.tester.tap(step3_button) + await flet_app_function.tester.pump_and_settle() + final_text = await flet_app_function.tester.find_by_text("Flow complete!") + assert final_text.count == 1 + + # Click "Finish and go Home" — triggers pop_views_until + finish_button = await flet_app_function.tester.find_by_text_containing( + "Finish and go Home" + ) + assert finish_button.count == 1 + await flet_app_function.tester.tap(finish_button) + await flet_app_function.tester.pump_and_settle() + + # Verify back at Home with result + result_text = await flet_app_function.tester.find_by_text("Result: Flow completed!") + assert result_text.count == 1 + + # Verify we can start the flow again + button = await flet_app_function.tester.find_by_text_containing("Start flow") + assert button.count == 1 diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index dbdaee2877..b8c67ccdce 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -410,6 +410,7 @@ PlatformBrightnessChangeEvent, RouteChangeEvent, ViewPopEvent, + ViewsPopUntilEvent, ) from flet.controls.painting import ( Paint, @@ -1073,6 +1074,7 @@ "VerticalDivider", "View", "ViewPopEvent", + "ViewsPopUntilEvent", "VisualDensity", "Wakelock", "WebBrowserName", diff --git a/sdk/python/packages/flet/src/flet/controls/page.py b/sdk/python/packages/flet/src/flet/controls/page.py index 1ca0dcb1c3..c8fe4275b2 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -214,6 +214,32 @@ class ViewPopEvent(Event["Page"]): """ +@dataclass +class ViewsPopUntilEvent(Event["Page"]): + """ + Event payload delivered when :meth:`~flet.Page.pop_views_until` completes \ + navigation. + + Carries the result value back to the destination view. + """ + + route: str + """ + Route of the destination view that remained on the stack. + """ + + result: Any = None + """ + The result value passed from the caller of + :meth:`~flet.Page.pop_views_until`. + """ + + view: Optional[View] = None + """ + Matched :class:`~flet.View` instance for `route`, if found on the page. + """ + + @dataclass class KeyboardEvent(Event["Page"]): """ @@ -506,6 +532,13 @@ class Page(BasePage): control. """ + on_views_pop_until: Optional[EventHandler[ViewsPopUntilEvent]] = None + """ + Called when :meth:`pop_views_until` reaches the destination view. + + The event carries the result value passed by the caller. + """ + on_keyboard_event: Optional[EventHandler[KeyboardEvent]] = None """ Called when a keyboard key is pressed. @@ -707,7 +740,7 @@ def before_event(self, e: ControlEvent): self.__last_route = e.route self.query() - elif isinstance(e, ViewPopEvent): + elif isinstance(e, ViewPopEvent | ViewsPopUntilEvent): for v in unwrap_component(self.views): v = unwrap_component(v) if v.route == e.route: @@ -897,6 +930,71 @@ async def view_pop(e): arguments={"route": new_route}, ) + async def pop_views_until(self, route: str, result: Any = None) -> None: + """ + Pops views from the navigation stack until a view with the given + `route` is found, then delivers `result` via the + :attr:`on_views_pop_until` event. + + Example: + ```python + import flet as ft + + + def main(page: ft.Page): + def on_pop_result(e: ft.ViewsPopUntilEvent): + page.show_dialog(ft.SnackBar(ft.Text(f"Result: {e.result}"))) + + page.on_views_pop_until = on_pop_result + + # ... later, from a deeply nested view: + async def go_back(ev): + await page.pop_views_until("/", result="Done!") + ``` + + Args: + route: Target route to navigate back to. Must match the `route` + of an existing :class:`~flet.View` in :attr:`~flet.Page.views`. + result: Optional value delivered to + :attr:`on_views_pop_until` on the destination view. + + Raises: + ValueError: If no view with the given `route` exists in + :attr:`~flet.Page.views`. + """ + views = unwrap_component(self.views) + + # Find the target view (first match from bottom of the stack) + target_idx = None + for i, v in enumerate(views): + v = unwrap_component(v) + if v.route == route: + target_idx = i + break + + if target_idx is None: + raise ValueError(f"No view found with route '{route}' in page.views") + + # Remove views above the target + del self.views[target_idx + 1 :] + + # Update browser URL + await self.push_route(route) + + # Fire on_views_pop_until for the destination view + if self.on_views_pop_until: + target_view = unwrap_component(views[target_idx]) + e = ViewsPopUntilEvent( + name="views_pop_until", + control=self, + route=route, + result=result, + view=target_view, + ) + await self._trigger_event("views_pop_until", event_data=None, e=e) + + self.update() + def get_upload_url(self, file_name: str, expires: int) -> str: """ Generates presigned upload URL for built-in upload storage: diff --git a/website/docs/cookbook/navigation-and-routing.md b/website/docs/cookbook/navigation-and-routing.md index b1e07eb1fa..2b1a406c90 100644 --- a/website/docs/cookbook/navigation-and-routing.md +++ b/website/docs/cookbook/navigation-and-routing.md @@ -72,6 +72,15 @@ and confirm manually with [`View.can_pop`](../controls/view.md#flet.View.can_pop +## Pop multiple views with a result + +When a multi-step flow finishes deep in the view stack, use +[`page.pop_views_until()`](../controls/page.md#flet.Page.pop_views_until) +to jump back to a target route and deliver a result to the destination view +via [`page.on_views_pop_until`](../controls/page.md#flet.Page.on_views_pop_until). + + + ## Navigation UI patterns Routing composes well with navigation controls such as drawer, rail, and tabs. diff --git a/website/static/docs/assets/navigation-routing/pop-until-with-result-example.gif b/website/static/docs/assets/navigation-routing/pop-until-with-result-example.gif new file mode 100644 index 0000000000..753a9715ac Binary files /dev/null and b/website/static/docs/assets/navigation-routing/pop-until-with-result-example.gif differ