diff --git a/packages/flet/lib/src/controls/alert_dialog.dart b/packages/flet/lib/src/controls/alert_dialog.dart index 405b201b3f..908f8b35cb 100644 --- a/packages/flet/lib/src/controls/alert_dialog.dart +++ b/packages/flet/lib/src/controls/alert_dialog.dart @@ -96,6 +96,7 @@ class AlertDialogControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog( barrierDismissible: !modal, // Render the barrier in the dialog widget so it updates live. @@ -103,11 +104,19 @@ class AlertDialogControl extends StatelessWidget { useSafeArea: false, useRootNavigator: false, context: context, - builder: (context) => _createAlertDialog(context)).then((value) { - debugPrint("Dismissing AlertDialog(${control.id})"); - control.updateProperties({"_open": false}, python: false); - control.updateProperties({"open": false}); - control.triggerEvent("dismiss"); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return _createAlertDialog(context); + }).then((value) { + // showDialog future completes on pop() — before the exit animation + // finishes. Wait for the route's transition to fully complete so + // the dismiss event fires after the closing animation ends. + (dialogRoute?.completed ?? Future.value()).then((_) { + debugPrint("Dismissing AlertDialog(${control.id})"); + control.updateProperties({"_open": false}, python: false); + control.updateProperties({"open": false}); + control.triggerEvent("dismiss"); + }); }); }); } else if (!open && lastOpen) { diff --git a/packages/flet/lib/src/controls/bottom_sheet.dart b/packages/flet/lib/src/controls/bottom_sheet.dart index a33c752a83..1f92ab7577 100644 --- a/packages/flet/lib/src/controls/bottom_sheet.dart +++ b/packages/flet/lib/src/controls/bottom_sheet.dart @@ -33,9 +33,11 @@ class BottomSheetControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? sheetRoute; showModalBottomSheet( context: context, builder: (context) { + sheetRoute ??= ModalRoute.of(context); var content = control.buildWidget("content"); if (content == null) { @@ -73,9 +75,11 @@ class BottomSheetControl extends StatelessWidget { shape: control.getOutlinedBorder("shape", Theme.of(context)), useSafeArea: control.getBool("use_safe_area", true)!) .then((value) { - control.updateProperties({"_open": false}, python: false); - control.updateProperties({"open": false}); - control.triggerEvent("dismiss"); + (sheetRoute?.completed ?? Future.value()).then((_) { + control.updateProperties({"_open": false}, python: false); + control.updateProperties({"open": false}); + control.triggerEvent("dismiss"); + }); }); }); } else if (open != lastOpen && lastOpen) { diff --git a/packages/flet/lib/src/controls/cupertino_alert_dialog.dart b/packages/flet/lib/src/controls/cupertino_alert_dialog.dart index 50fee68210..3c3326d614 100644 --- a/packages/flet/lib/src/controls/cupertino_alert_dialog.dart +++ b/packages/flet/lib/src/controls/cupertino_alert_dialog.dart @@ -75,6 +75,7 @@ class CupertinoAlertDialogControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog( barrierDismissible: !modal, // Render the barrier in the dialog widget so it updates live. @@ -82,11 +83,16 @@ class CupertinoAlertDialogControl extends StatelessWidget { useSafeArea: false, useRootNavigator: false, context: context, - builder: (context) => _createCupertinoAlertDialog()).then((value) { - debugPrint("Dismissing CupertinoAlertDialog(${control.id})"); - control.updateProperties({"_open": false}, python: false); - control.updateProperties({"open": false}); - control.triggerEvent("dismiss"); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return _createCupertinoAlertDialog(); + }).then((value) { + (dialogRoute?.completed ?? Future.value()).then((_) { + debugPrint("Dismissing CupertinoAlertDialog(${control.id})"); + control.updateProperties({"_open": false}, python: false); + control.updateProperties({"open": false}); + control.triggerEvent("dismiss"); + }); }); }); } else if (!open && lastOpen) { diff --git a/packages/flet/lib/src/controls/cupertino_bottom_sheet.dart b/packages/flet/lib/src/controls/cupertino_bottom_sheet.dart index 6e84bc0798..9c9d958fa7 100644 --- a/packages/flet/lib/src/controls/cupertino_bottom_sheet.dart +++ b/packages/flet/lib/src/controls/cupertino_bottom_sheet.dart @@ -64,14 +64,20 @@ class CupertinoBottomSheetControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? popupRoute; showCupertinoModalPopup( barrierDismissible: !control.getBool("modal", false)!, useRootNavigator: false, context: context, - builder: (context) => dialog).then((value) { - control.updateProperties({"_open": false}, python: false); - control.updateProperties({"open": false}); - control.triggerEvent("dismiss"); + builder: (context) { + popupRoute ??= ModalRoute.of(context); + return dialog; + }).then((value) { + (popupRoute?.completed ?? Future.value()).then((_) { + control.updateProperties({"_open": false}, python: false); + control.updateProperties({"open": false}); + control.triggerEvent("dismiss"); + }); }); }); } else if (open != lastOpen && lastOpen && Navigator.of(context).canPop()) { diff --git a/packages/flet/lib/src/controls/date_picker.dart b/packages/flet/lib/src/controls/date_picker.dart index eea5003a95..48c12bb332 100644 --- a/packages/flet/lib/src/controls/date_picker.dart +++ b/packages/flet/lib/src/controls/date_picker.dart @@ -81,14 +81,20 @@ class DatePickerControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog( barrierDismissible: !control.getBool("modal", false)!, barrierColor: control.getColor("barrier_color", context), useRootNavigator: false, context: context, - builder: (context) => createSelectDateDialog()).then((result) { - debugPrint("pickDate() completed"); - onClosed(result); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return createSelectDateDialog(); + }).then((result) { + (dialogRoute?.completed ?? Future.value()).then((_) { + debugPrint("pickDate() completed"); + onClosed(result); + }); }); }); } diff --git a/packages/flet/lib/src/controls/date_range_picker.dart b/packages/flet/lib/src/controls/date_range_picker.dart index 7f7b8e4d41..ada8ed61eb 100644 --- a/packages/flet/lib/src/controls/date_range_picker.dart +++ b/packages/flet/lib/src/controls/date_range_picker.dart @@ -86,14 +86,20 @@ class DateRangePickerControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog>( barrierDismissible: !control.getBool("modal", false)!, barrierColor: control.getColor("barrier_color", context), useRootNavigator: false, context: context, - builder: (context) => createSelectDateDialog()).then((result) { - debugPrint("pickDate() completed"); - onClosed(result); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return createSelectDateDialog(); + }).then((result) { + (dialogRoute?.completed ?? Future.value()).then((_) { + debugPrint("pickDate() completed"); + onClosed(result); + }); }); }); } diff --git a/packages/flet/lib/src/controls/time_picker.dart b/packages/flet/lib/src/controls/time_picker.dart index f7491774c3..fa3158fec0 100644 --- a/packages/flet/lib/src/controls/time_picker.dart +++ b/packages/flet/lib/src/controls/time_picker.dart @@ -76,13 +76,19 @@ class TimePickerControl extends StatelessWidget { control.updateProperties({"_open": open}, python: false); WidgetsBinding.instance.addPostFrameCallback((_) { + ModalRoute? dialogRoute; showDialog( barrierColor: control.getColor("barrier_color", context), barrierDismissible: !control.getBool("modal", false)!, useRootNavigator: false, context: context, - builder: (context) => createSelectTimeDialog()).then((result) { - onClosed(result); + builder: (context) { + dialogRoute ??= ModalRoute.of(context); + return createSelectTimeDialog(); + }).then((result) { + (dialogRoute?.completed ?? Future.value()).then((_) { + onClosed(result); + }); }); }); } diff --git a/sdk/python/examples/apps/declarative/use_dialog_basic.py b/sdk/python/examples/apps/declarative/use_dialog_basic.py new file mode 100644 index 0000000000..d446143005 --- /dev/null +++ b/sdk/python/examples/apps/declarative/use_dialog_basic.py @@ -0,0 +1,56 @@ +import asyncio + +import flet as ft + + +@ft.component +def App(): + show, set_show = ft.use_state(False) + deleting, set_deleting = ft.use_state(False) + + async def handle_delete(): + set_deleting(True) + # Simulate async operation + await asyncio.sleep(2) + set_deleting(False) + set_show(False) + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text( + "Deleting, please wait..." if deleting else "This cannot be undone." + ), + actions=[ + ft.Button( + "Deleting..." if deleting else "Delete", + disabled=deleting, + on_click=handle_delete, + ), + ft.TextButton( + "Cancel", + on_click=lambda: set_show(False), + disabled=deleting, + ), + ], + on_dismiss=lambda: set_show(False), + ) + if show + else None + ) + + return ft.Column( + controls=[ + ft.Text("Declarative Dialog Example", size=24, weight=ft.FontWeight.BOLD), + ft.Text("Click the button to open a confirmation dialog."), + ft.Button( + "Delete File", + icon=ft.Icons.DELETE, + on_click=lambda: set_show(True), + ), + ], + ) + + +ft.run(lambda page: page.render(App)) diff --git a/sdk/python/examples/apps/declarative/use_dialog_chained.py b/sdk/python/examples/apps/declarative/use_dialog_chained.py new file mode 100644 index 0000000000..2bb79ae3dd --- /dev/null +++ b/sdk/python/examples/apps/declarative/use_dialog_chained.py @@ -0,0 +1,81 @@ +import asyncio + +import flet as ft + + +@ft.component +def App(): + show_confirm, set_show_confirm = ft.use_state(False) + show_success, set_show_success = ft.use_state(False) + deleting, set_deleting = ft.use_state(False) + deleted, set_deleted = ft.use_state(False) + # Ref avoids stale closure — always holds the current value + should_chain = ft.use_ref(False) + + async def handle_delete(): + set_deleting(True) + await asyncio.sleep(2) + set_deleting(False) + set_deleted(True) + should_chain.current = True + set_show_confirm(False) + + def on_confirm_dismiss(): + if should_chain.current: + should_chain.current = False + set_show_success(True) + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text( + "Deleting, please wait..." if deleting else "This cannot be undone." + ), + actions=[ + ft.Button( + "Deleting..." if deleting else "Delete", + disabled=deleting, + on_click=handle_delete, + ), + ft.TextButton( + "Cancel", + on_click=lambda: set_show_confirm(False), + disabled=deleting, + ), + ], + on_dismiss=on_confirm_dismiss, + ) + if show_confirm + else None + ) + + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Done!"), + content=ft.Text("report.pdf has been deleted."), + actions=[ + ft.FilledButton("OK", on_click=lambda: set_show_success(False)), + ], + ) + if show_success + else None + ) + + return ft.Column( + controls=[ + ft.Text("Chained Dialogs Example", size=24, weight=ft.FontWeight.BOLD), + ft.Text( + "File deleted." if deleted else "Click the button to delete the file." + ), + ft.Button( + "Delete File", + icon=ft.Icons.DELETE, + on_click=lambda: set_show_confirm(True), + disabled=deleted, + ), + ], + ) + + +ft.run(lambda page: page.render(App)) diff --git a/sdk/python/examples/apps/declarative/use_dialog_multiple.py b/sdk/python/examples/apps/declarative/use_dialog_multiple.py new file mode 100644 index 0000000000..a119f18246 --- /dev/null +++ b/sdk/python/examples/apps/declarative/use_dialog_multiple.py @@ -0,0 +1,148 @@ +import asyncio + +import flet as ft + + +def build_rename_dialog(current_name, new_name, on_name_change, on_save, on_cancel): + return ft.AlertDialog( + modal=True, + title=ft.Text(f"Rename {current_name}"), + content=ft.TextField( + label="New name", + value=new_name, + on_change=on_name_change, + autofocus=True, + ), + actions=[ + ft.FilledButton("Save", on_click=on_save), + ft.TextButton("Cancel", on_click=on_cancel), + ], + ) + + +def build_delete_dialog(item_name, deleting, on_delete, on_cancel): + return ft.AlertDialog( + modal=True, + title=ft.Text(f"Delete {item_name}?"), + content=ft.Text( + "Deleting, please wait..." if deleting else "This action cannot be undone." + ), + actions=[ + ft.Button( + "Deleting..." if deleting else "Delete", + color=ft.Colors.WHITE, + bgcolor=ft.Colors.RED if not deleting else ft.Colors.GREY, + disabled=deleting, + on_click=on_delete, + ), + ft.TextButton("Cancel", on_click=on_cancel, disabled=deleting), + ], + ) + + +@ft.component +def FileItem(name, on_rename, on_delete): + return ft.Card( + content=ft.Container( + padding=20, + content=ft.Row( + controls=[ + ft.Icon(ft.Icons.DESCRIPTION), + ft.Text(name, size=16, expand=True), + ft.IconButton( + ft.Icons.EDIT, + tooltip="Rename", + on_click=on_rename, + ), + ft.IconButton( + ft.Icons.DELETE, + tooltip="Delete", + icon_color=ft.Colors.RED, + on_click=on_delete, + ), + ], + alignment=ft.MainAxisAlignment.START, + ), + ), + ) + + +INITIAL_FILES = ["report.pdf", "photo.jpg", "notes.txt"] + + +@ft.component +def App(): + files, set_files = ft.use_state(INITIAL_FILES) + target, set_target = ft.use_state(None) # file being acted on + new_name, set_new_name = ft.use_state("") + show_rename, set_show_rename = ft.use_state(False) + show_delete, set_show_delete = ft.use_state(False) + deleting, set_deleting = ft.use_state(False) + + async def handle_delete(): + set_deleting(True) + await asyncio.sleep(1) + set_files([f for f in files if f != target]) + set_deleting(False) + set_show_delete(False) + + def handle_rename_save(): + if new_name.strip(): + set_files([new_name.strip() if f == target else f for f in files]) + set_show_rename(False) + + def open_rename(name): + def handler(): + set_target(name) + set_new_name(name) + set_show_rename(True) + + return handler + + def open_delete(name): + def handler(): + set_target(name) + set_show_delete(True) + + return handler + + ft.use_dialog( + build_rename_dialog( + target, + new_name, + on_name_change=lambda e: set_new_name(e.control.value), + on_save=handle_rename_save, + on_cancel=lambda: set_show_rename(False), + ) + if show_rename + else None + ) + + ft.use_dialog( + build_delete_dialog( + target, + deleting, + on_delete=handle_delete, + on_cancel=lambda: set_show_delete(False), + ) + if show_delete + else None + ) + + return ft.Column( + controls=[ + ft.Text("Multiple Dialogs Example", size=24, weight=ft.FontWeight.BOLD), + ft.Text("Each file can be renamed or deleted."), + ] + + [ + FileItem( + name=f, + on_rename=open_rename(f), + on_delete=open_delete(f), + ) + for f in files + ], + ) + + +ft.run(lambda page: page.render(App)) diff --git a/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md b/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md new file mode 100644 index 0000000000..893afe715f --- /dev/null +++ b/sdk/python/packages/flet/docs/cookbook/declarative-dialogs.md @@ -0,0 +1,200 @@ +# Declarative dialogs + +[`ft.use_dialog()`][flet.use_dialog] lets a component show and update dialogs declaratively. +Instead of imperatively calling [`page.show_dialog()`][flet.Page.show_dialog] and later +remembering to close or remove the dialog, you render a [`DialogControl`][flet.DialogControl] +from component state: + +- pass a dialog instance to show it; +- pass `None` to hide it. + +This keeps dialog logic in the same state flow as the rest of a declarative app: + +- state decides whether the dialog is visible; +- dialog content updates when state changes; +- there is no `page.update()` call in the component. + +## Basic pattern + +Call [`ft.use_dialog()`][flet.use_dialog] on every render. When the dialog should be open, +return a dialog control; otherwise return `None`. + +```python +import flet as ft + + +@ft.component +def App(): + show, set_show = ft.use_state(False) + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text("This cannot be undone."), + actions=[ + ft.TextButton("Delete", on_click=lambda: set_show(False)), + ft.TextButton("Cancel", on_click=lambda: set_show(False)), + ], + on_dismiss=lambda: set_show(False), + ) + if show + else None + ) + + return ft.Column( + controls=[ + ft.TextButton("Open dialog", on_click=lambda: set_show(True)), + ] + ) + + +ft.run(lambda page: page.render(App)) +``` + +The important part is that `show` is the source of truth. The dialog is not opened by +mutating the page tree directly; it appears because the component renders it. + +## Updating dialog content from state + +Because the dialog is declarative, its content can react to state changes while it is open. +This is useful for confirmations, form validation, and async workflows: + +```python +import asyncio +import flet as ft + + +@ft.component +def App(): + show, set_show = ft.use_state(False) + deleting, set_deleting = ft.use_state(False) + + async def handle_delete(): + set_deleting(True) + await asyncio.sleep(2) + set_deleting(False) + set_show(False) + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text( + "Deleting, please wait..." if deleting else "This cannot be undone." + ), + actions=[ + ft.Button( + "Deleting..." if deleting else "Delete", + disabled=deleting, + on_click=handle_delete, + ), + ft.TextButton( + "Cancel", + disabled=deleting, + on_click=lambda: set_show(False), + ), + ], + on_dismiss=lambda: set_show(False), + ) + if show + else None + ) + + return ft.TextButton("Delete file", on_click=lambda: set_show(True)) + +ft.run(lambda page: page.render(App)) +``` + +This pattern works well with `asyncio` and other async APIs in Flet apps. For more +background, see [Async apps](async-apps.md). + +## Chaining dialogs + +You can call [`ft.use_dialog()`][flet.use_dialog] more than once in the same component. That +makes follow-up flows straightforward, for example: + +- confirmation dialog; +- async action; +- success dialog after the first dialog fully closes. + +```python +import flet as ft + + +@ft.component +def App(): + show_confirm, set_show_confirm = ft.use_state(False) + show_success, set_show_success = ft.use_state(False) + should_chain = ft.use_ref(False) + + def confirm_delete(): + should_chain.current = True + set_show_confirm(False) + + def on_confirm_dismiss(): + if should_chain.current: + should_chain.current = False + set_show_success(True) + + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Delete file?"), + actions=[ + ft.TextButton("Delete", on_click=confirm_delete), + ft.TextButton("Cancel", on_click=lambda: set_show_confirm(False)), + ], + on_dismiss=on_confirm_dismiss, + ) + if show_confirm + else None + ) + + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Done"), + content=ft.Text("The file was deleted."), + actions=[ + ft.TextButton("OK", on_click=lambda: set_show_success(False)), + ], + ) + if show_success + else None + ) + + return ft.TextButton("Open", on_click=lambda: set_show_confirm(True)) + +ft.run(lambda page: page.render(App)) +``` + +[`ft.use_ref()`][flet.use_ref] is helpful here because the value survives re-renders without +causing another render by itself. + +## `on_dismiss` timing + +[`DialogControl.on_dismiss`][flet.DialogControl.on_dismiss] fires after the dialog close +animation completes, not immediately when `open` changes to `False`. This makes it safe to +start follow-up UI after the dialog has actually finished closing. + +Use `on_dismiss` for logic that should happen after the dialog is fully gone, such as: + +- opening the next dialog in a chain; +- resetting temporary dialog-local state; +- starting a follow-up animation or toast. + +## When to use `page.show_dialog()` instead + +[`ft.use_dialog()`][flet.use_dialog] is a better fit inside [`@ft.component`][flet.component] +functions and other declarative flows. + +[`page.show_dialog()`][flet.Page.show_dialog] is still a good option when: + +- the app is written imperatively; +- dialog lifecycle is handled outside the component tree; +- you need to trigger a dialog from existing page-level event code and do not want to convert + that part of the app to declarative style yet. + +In practice, the two APIs serve different styles: + +- use declarative dialogs when UI should follow component state; +- use imperative dialogs when UI is managed by direct page mutation. diff --git a/sdk/python/packages/flet/docs/types/usedialog.md b/sdk/python/packages/flet/docs/types/usedialog.md new file mode 100644 index 0000000000..0d58a68285 --- /dev/null +++ b/sdk/python/packages/flet/docs/types/usedialog.md @@ -0,0 +1 @@ +{{ class_all_options("flet.use_dialog") }} diff --git a/sdk/python/packages/flet/integration_tests/examples/material/test_alert_dialog.py b/sdk/python/packages/flet/integration_tests/examples/material/test_alert_dialog.py index 2d939c74b6..da4def8ca0 100644 --- a/sdk/python/packages/flet/integration_tests/examples/material/test_alert_dialog.py +++ b/sdk/python/packages/flet/integration_tests/examples/material/test_alert_dialog.py @@ -74,3 +74,62 @@ async def test_basic(flet_app_function: ftt.FletTestApp): "alert_dialog_flow", duration=2000, ) + + +@pytest.mark.asyncio(loop_scope="function") +async def test_use_dialog_dismiss_fires_after_animation( + flet_app_function: ftt.FletTestApp, +): + @ft.component + def App(): + show, set_show = ft.use_state(False) + status, set_status = ft.use_state("waiting") + + ft.use_dialog( + ft.AlertDialog( + modal=True, + title=ft.Text("Delete report.pdf?"), + content=ft.Text("This cannot be undone."), + actions=[ + ft.TextButton("Close", on_click=lambda: set_show(False)), + ], + on_dismiss=lambda: set_status("dismissed"), + ) + if show + else None + ) + + return ft.Column( + controls=[ + ft.Text(status), + ft.TextButton("Open", on_click=lambda: set_show(True)), + ] + ) + + flet_app_function.page.render(App) + await flet_app_function.tester.pump_and_settle() + + await flet_app_function.tester.tap( + await flet_app_function.tester.find_by_text("Open") + ) + await flet_app_function.tester.pump_and_settle() + + assert (await flet_app_function.tester.find_by_text("waiting")).count == 1 + assert ( + await flet_app_function.tester.find_by_text("Delete report.pdf?") + ).count == 1 + + await flet_app_function.tester.tap( + await flet_app_function.tester.find_by_text("Close") + ) + await flet_app_function.tester.pump() + + assert (await flet_app_function.tester.find_by_text("dismissed")).count == 0 + assert (await flet_app_function.tester.find_by_text("waiting")).count == 1 + + await flet_app_function.tester.pump_and_settle() + + assert (await flet_app_function.tester.find_by_text("dismissed")).count == 1 + assert ( + await flet_app_function.tester.find_by_text("Delete report.pdf?") + ).count == 0 diff --git a/sdk/python/packages/flet/mkdocs.yml b/sdk/python/packages/flet/mkdocs.yml index 3d403c4f3b..cc0468cdba 100644 --- a/sdk/python/packages/flet/mkdocs.yml +++ b/sdk/python/packages/flet/mkdocs.yml @@ -1002,6 +1002,7 @@ nav: - on_updated: types/onupdated.md - use_callback: types/usecallback.md - use_context: types/usecontext.md + - use_dialog: types/usedialog.md - use_effect: types/useeffect.md - use_memo: types/usememo.md - use_ref: types/useref.md @@ -1020,6 +1021,7 @@ nav: - Colors: cookbook/colors.md - Control Refs: cookbook/control-refs.md - Custom Controls: cookbook/custom-controls.md + - Declarative dialogs: cookbook/declarative-dialogs.md - Drag and Drop: cookbook/drag-and-drop.md - Declarative vs Imperative CRUD app: cookbook/declarative-vs-imperative-crud-app.md - Encrypting sensitive data: cookbook/encrypting-sensitive-data.md diff --git a/sdk/python/packages/flet/src/flet/__init__.py b/sdk/python/packages/flet/src/flet/__init__.py index 45df642267..dbdaee2877 100644 --- a/sdk/python/packages/flet/src/flet/__init__.py +++ b/sdk/python/packages/flet/src/flet/__init__.py @@ -5,6 +5,7 @@ from flet.components.component_decorator import component from flet.components.hooks.use_callback import use_callback from flet.components.hooks.use_context import create_context, use_context +from flet.components.hooks.use_dialog import use_dialog from flet.components.hooks.use_effect import ( on_mounted, on_unmounted, @@ -1111,6 +1112,7 @@ "unwrap_component", "use_callback", "use_context", + "use_dialog", "use_effect", "use_memo", "use_ref", diff --git a/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py b/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py new file mode 100644 index 0000000000..a1abfc4c75 --- /dev/null +++ b/sdk/python/packages/flet/src/flet/components/hooks/use_dialog.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from flet.components.hooks.use_effect import use_effect +from flet.components.hooks.use_ref import use_ref +from flet.controls.context import context +from flet.controls.dialog_control import DialogControl + + +def use_dialog(dialog: DialogControl | None = None): + """ + Portal a [`DialogControl`][flet.DialogControl] to the page's dialog overlay. + + Call this hook inside a [`@component`][flet.component] function on every render. + Pass a [`DialogControl`][flet.DialogControl] instance to show the dialog, + or `None` to hide/remove it. The dialog content is updated + reactively on each re-render. + + The hook automatically sets `open=True` on the dialog when it is + added to the overlay and removes it when `None` is passed or the + component unmounts. + + Args: + dialog: A [`DialogControl`][flet.DialogControl] to display, or `None` + to hide it. + """ + ref = use_ref(None) + page = context.page + + prev = ref.current + + if prev is not None and prev not in page._dialogs.controls: + ref.current = None + prev = None + + if dialog is not None: + dialog.open = True + page._prepare_dialog(dialog) + if prev is not None and prev in page._dialogs.controls: + # Frozen diff: compares prev and dialog field-by-field, + # sends only actual deltas, and transfers _i via _migrate_state + # so Flutter preserves widget identity (e.g. TextField cursor). + page.session.patch_control(control=dialog, prev_control=prev, frozen=True) + # Strip _frozen set by _dataclass_added during frozen diff + # so we can later set open=False for dismissal. + if hasattr(dialog, "_frozen"): + del dialog._frozen + idx = page._dialogs.controls.index(prev) + page._dialogs.controls[idx] = dialog + ref.current = dialog + else: + page._dialogs.controls.append(dialog) + ref.current = dialog + page.session.schedule_update(page._dialogs) + elif prev is not None and prev.open: + # Dismiss: patch open=False so Flutter pops the dialog route. + # Keep prev in _dialogs.controls and ref.current so the dialog + # stays alive in session.__index (WeakValueDictionary) — Flutter + # needs the control present to dispatch the dismiss event. The + # wrapped dismiss handler removes it after Flutter signals close. + prev.open = False + page.session.patch_control(prev) + + def _cleanup(): + d = ref.current + if d is not None: + ref.current = None + if d.open: + d.open = False + page.session.patch_control(d) + + use_effect(lambda: None, dependencies=[], cleanup=_cleanup) 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 e76b550ce3..e09685e2a8 100644 --- a/sdk/python/packages/flet/src/flet/controls/base_page.py +++ b/sdk/python/packages/flet/src/flet/controls/base_page.py @@ -1,4 +1,5 @@ import logging +import weakref from dataclasses import dataclass, field from typing import ( TYPE_CHECKING, @@ -44,6 +45,9 @@ logger = logging.getLogger("flet") +_MANAGED_DIALOG_DISMISS_ORIGINAL = "_managed_dialog_dismiss_original" +_MANAGED_DIALOG_DISMISS_WRAPPER = "_managed_dialog_dismiss_wrapper" + if TYPE_CHECKING: from flet.controls.theme import Theme @@ -388,30 +392,8 @@ def show_dialog(self, dialog: DialogControl) -> None: if dialog in self._dialogs.controls: raise RuntimeError("Dialog is already opened") - original_on_dismiss = dialog.on_dismiss - - async def wrapped_on_dismiss(*args): - """ - Remove dialog from stack and forward dismiss event to original handler. - - Args: - *args: Dismiss event arguments passed by the framework. - """ - - if dialog in self._dialogs.controls: - self._dialogs.controls.remove(dialog) - self._dialogs.update() - dialog.on_dismiss = original_on_dismiss - e = args[0] - if ( - original_on_dismiss and (e.data is None or e.data) # e.data == True for - # TimePicker and DatePicker if they were dismissed without - # changing the value - ): - await dialog._trigger_event("dismiss", e) - dialog.open = True - dialog.on_dismiss = wrapped_on_dismiss + self._prepare_dialog(dialog) self._dialogs.controls.append(dialog) self._dialogs.update() @@ -436,6 +418,52 @@ def pop_dialog(self) -> Optional[DialogControl]: dialog.update() return dialog + def _prepare_dialog(self, dialog: DialogControl) -> None: + self._set_dialog_parent(dialog) + self._wrap_dialog_on_dismiss(dialog) + + def _set_dialog_parent(self, dialog: DialogControl) -> None: + dialog._parent = weakref.ref(self._dialogs) + + def _get_original_dialog_on_dismiss(self, dialog: DialogControl): + wrapper = getattr(dialog, _MANAGED_DIALOG_DISMISS_WRAPPER, None) + if wrapper is not None and dialog.on_dismiss is wrapper: + return getattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL, None) + return dialog.on_dismiss + + def _wrap_dialog_on_dismiss(self, dialog: DialogControl) -> None: + original_on_dismiss = self._get_original_dialog_on_dismiss(dialog) + + async def wrapped_on_dismiss(*args): + # Keep the dialog mounted until Flutter reports dismiss. Removing it + # earlier can drop the post-animation dismiss callback entirely. + self._restore_dialog_on_dismiss(dialog) + self._remove_dialog(dialog) + e = args[0] + if ( + original_on_dismiss and (e.data is None or e.data) + # e.data == True for TimePicker and DatePicker if they were + # dismissed without changing the value + ): + await dialog._trigger_event("dismiss", e) + + setattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL, original_on_dismiss) + setattr(dialog, _MANAGED_DIALOG_DISMISS_WRAPPER, wrapped_on_dismiss) + dialog.on_dismiss = wrapped_on_dismiss + + def _restore_dialog_on_dismiss(self, dialog: DialogControl) -> None: + original_on_dismiss = getattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL, None) + if hasattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL): + delattr(dialog, _MANAGED_DIALOG_DISMISS_ORIGINAL) + if hasattr(dialog, _MANAGED_DIALOG_DISMISS_WRAPPER): + delattr(dialog, _MANAGED_DIALOG_DISMISS_WRAPPER) + dialog.on_dismiss = original_on_dismiss + + def _remove_dialog(self, dialog: DialogControl) -> None: + if dialog in self._dialogs.controls: + self._dialogs.controls.remove(dialog) + self._dialogs.update() + async def show_drawer(self): """ Show the drawer. diff --git a/sdk/python/packages/flet/tests/test_use_dialog.py b/sdk/python/packages/flet/tests/test_use_dialog.py new file mode 100644 index 0000000000..6b93adaaf7 --- /dev/null +++ b/sdk/python/packages/flet/tests/test_use_dialog.py @@ -0,0 +1,166 @@ +import weakref + +import pytest + +import flet as ft +from flet.components.component import Component, Renderer +from flet.controls.base_control import BaseControl +from flet.controls.context import _context_page +from flet.controls.control_event import ControlEvent +from flet.messaging.protocol import configure_encode_object_for_msgpack + + +class FakeSession: + def __init__(self): + self.patch_calls: list[tuple[object, dict]] = [] + self.scheduled_updates: list[object] = [] + self.index: dict[int, object] = {} + + def patch_control(self, control, **kwargs): + self.patch_calls.append((control, kwargs)) + control_id = getattr(control, "_i", None) + if control_id is not None: + self.index[control_id] = control + + def schedule_update(self, control): + self.scheduled_updates.append(control) + + def schedule_effect(self, hook, is_cleanup): + pass + + async def after_event(self, control): + pass + + +def render_component(component: Component, page: ft.Page) -> None: + component._state.hook_cursor = 0 + token = _context_page.set(page) + try: + renderer = Renderer(component) + with renderer.with_context(), renderer._Frame(renderer, component): + component.fn(*component.args, **component.kwargs) + finally: + _context_page.reset(token) + + +def create_page() -> tuple[ft.Page, FakeSession]: + session = FakeSession() + page = ft.Page(sess=session) + page._dialogs._parent = weakref.ref(page) + return page, session + + +@pytest.mark.asyncio +async def test_use_dialog_waits_for_dismiss_before_removing_dialog(): + page, session = create_page() + show = True + dismiss_calls: list[str] = [] + + def body(): + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Hello"), + on_dismiss=lambda e: dismiss_calls.append(e.name), + ) + if show + else None + ) + + component = Component(fn=body, args=(), kwargs={}) + + render_component(component, page) + + assert session.scheduled_updates == [page._dialogs] + assert len(page._dialogs.controls) == 1 + + dialog = page._dialogs.controls[0] + session.patch_calls.clear() + session.scheduled_updates.clear() + + show = False + render_component(component, page) + + assert page._dialogs.controls == [dialog] + assert dialog.open is False + assert session.patch_calls == [(dialog, {})] + assert session.scheduled_updates == [] + + await dialog.on_dismiss(ControlEvent(control=dialog, name="dismiss", data=None)) + + assert page._dialogs.controls == [] + assert dismiss_calls == ["dismiss"] + assert session.patch_calls[-1] == (page._dialogs, {}) + + +@pytest.mark.asyncio +async def test_use_dialog_unmount_keeps_dialog_until_dismiss_event(): + page, session = create_page() + dismiss_calls: list[str] = [] + + def body(): + ft.use_dialog( + ft.AlertDialog( + title=ft.Text("Hello"), + on_dismiss=lambda e: dismiss_calls.append(e.name), + ) + ) + + component = Component(fn=body, args=(), kwargs={}) + render_component(component, page) + + dialog = page._dialogs.controls[0] + session.patch_calls.clear() + session.scheduled_updates.clear() + component._schedule_effect = lambda hook, is_cleanup=False: ( + hook.cleanup() if is_cleanup and hook.cleanup else None + ) + + component.will_unmount() + + assert page._dialogs.controls == [dialog] + assert dialog.open is False + assert session.patch_calls == [(dialog, {})] + assert session.scheduled_updates == [] + + await dialog.on_dismiss(ControlEvent(control=dialog, name="dismiss", data=None)) + + assert page._dialogs.controls == [] + assert dismiss_calls == ["dismiss"] + assert session.patch_calls[-1] == (page._dialogs, {}) + + +@pytest.mark.asyncio +async def test_show_dialog_still_removes_dialog_on_dismiss(): + page, session = create_page() + dismiss_calls: list[str] = [] + dialog = ft.AlertDialog( + title=ft.Text("Hello"), + on_dismiss=lambda e: dismiss_calls.append(e.name), + ) + + page.show_dialog(dialog) + + assert page._dialogs.controls == [dialog] + assert dialog.open is True + assert session.patch_calls == [(page._dialogs, {})] + + await dialog.on_dismiss(ControlEvent(control=dialog, name="dismiss", data=None)) + + assert page._dialogs.controls == [] + assert dismiss_calls == ["dismiss"] + assert session.patch_calls[-1] == (page._dialogs, {}) + + +def test_prepare_dialog_does_not_store_callbacks_in_serialized_fields(): + page, _ = create_page() + dialog = ft.AlertDialog( + title=ft.Text("Hello"), + on_dismiss=lambda e: None, + ) + + page._prepare_dialog(dialog) + + encoded = configure_encode_object_for_msgpack(BaseControl)(dialog) + + assert encoded["on_dismiss"] is True + assert "_internals" not in encoded