diff --git a/client/pubspec.lock b/client/pubspec.lock index 00553a8f3b..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.84.0" + version: "0.82.2" flet_ads: dependency: "direct main" description: @@ -911,18 +911,18 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.18" + 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: "93167629bfc610f71560ab9312acdda4959de4df6fac7492c89ff0d3886f6636" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.9" + version: "0.7.7" torch_light: dependency: transitive description: diff --git a/sdk/python/examples/apps/routing_navigation/pop_views_until.py b/sdk/python/examples/apps/routing_navigation/pop_views_until.py new file mode 100644 index 0000000000..a4251c14e2 --- /dev/null +++ b/sdk/python/examples/apps/routing_navigation/pop_views_until.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/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..355d6c076e 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, ) @@ -13,7 +14,7 @@ @pytest.mark.parametrize( "flet_app_function", - [{"flet_app_main": initial_route.main}], + [{"flet_app_main": initial_route}], indirect=True, ) @pytest.mark.asyncio(loop_scope="function") @@ -24,7 +25,7 @@ async def test_initial_route(flet_app_function: ftt.FletTestApp): @pytest.mark.parametrize( "flet_app_function", - [{"flet_app_main": route_change_event.main}], + [{"flet_app_main": route_change_event}], indirect=True, ) @pytest.mark.asyncio(loop_scope="function") @@ -39,7 +40,7 @@ async def test_route_change_event(flet_app_function: ftt.FletTestApp): @pytest.mark.parametrize( "flet_app_function", - [{"flet_app_main": home_store.main}], + [{"flet_app_main": home_store}], indirect=True, ) @pytest.mark.asyncio(loop_scope="function") @@ -75,7 +76,7 @@ async def test_home_store(flet_app_function: ftt.FletTestApp): @pytest.mark.parametrize( "flet_app_function", - [{"flet_app_main": pop_view_confirm.main}], + [{"flet_app_main": pop_view_confirm}], indirect=True, ) @pytest.mark.asyncio(loop_scope="function") @@ -133,7 +134,7 @@ async def test_pop_view_confirm(flet_app_function: ftt.FletTestApp): @pytest.mark.parametrize( "flet_app_function", - [{"flet_app_main": drawer_navigation.main}], + [{"flet_app_main": drawer_navigation}], indirect=True, ) @pytest.mark.asyncio(loop_scope="function") @@ -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}], + 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 45df642267..7b2d646340 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -409,6 +409,7 @@ PlatformBrightnessChangeEvent, RouteChangeEvent, ViewPopEvent, + ViewsPopUntilEvent, ) from flet.controls.painting import ( Paint, @@ -1072,6 +1073,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..903f2c0ec6 100644 --- a/sdk/python/packages/flet/src/flet/controls/page.py +++ b/sdk/python/packages/flet/src/flet/controls/page.py @@ -214,6 +214,33 @@ class ViewPopEvent(Event["Page"]): """ +@dataclass +class ViewsPopUntilEvent(Event["Page"]): + """ + Event payload delivered when [`Page.pop_views_until`]\ + [flet.Page.pop_views_until] completes navigation. + + Carries the result value back to the destination view, analogous to + Flutter's `Navigator.popUntilWithResult`. + """ + + route: str + """ + Route of the destination view that remained on the stack. + """ + + result: Any = None + """ + The result value passed from the caller of + [`pop_views_until`][flet.Page.pop_views_until]. + """ + + view: Optional[View] = None + """ + Matched [`View`][flet.View] instance for `route`, if found on the page. + """ + + @dataclass class KeyboardEvent(Event["Page"]): """ @@ -506,6 +533,14 @@ class Page(BasePage): control. """ + on_views_pop_until: Optional[EventHandler[ViewsPopUntilEvent]] = None + """ + Called when [`pop_views_until`][flet.Page.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 +742,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 +932,75 @@ 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 + [`on_views_pop_until`][flet.Page.on_views_pop_until] event. + + This is the Flet equivalent of Flutter's `Navigator.popUntilWithResult`. + + 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 [`View`][flet.View] in + [`page.views`][flet.Page.views]. + result: Optional value delivered to + [`on_views_pop_until`][flet.Page.on_views_pop_until] on the + destination view. + + Raises: + ValueError: If no view with the given `route` exists in + [`page.views`][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/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