diff --git a/fletx/core/page.py b/fletx/core/page.py index 1232d36..5c492c7 100644 --- a/fletx/core/page.py +++ b/fletx/core/page.py @@ -5,6 +5,7 @@ enabling the creation of interactive and dynamic user experiences. """ +import inspect import flet as ft from typing import ( Union, List, Optional, Any, Dict, Type, TypeVar, Callable, Tuple @@ -48,6 +49,18 @@ class FletXPage(ft.Container, ABC): for building robust and interactive pages. """ + NAVIGATION_COMPONENT_KEYS = { + 'app_bar', + 'bottom_app_bar', + 'drawer', + 'end_drawer', + 'navigation_bar', + 'floating_action_button', + 'floating_action_button_location' + } + __navigation_config__: Dict[str, Any] = {} + _MISSING_NAV = object() + def __init__( self, *, @@ -87,6 +100,11 @@ def __init__( # Reactive properties self._reactive_subscriptions: List[Any] = [] self._child_pages: weakref.WeakSet = weakref.WeakSet() + + # Navigation configuration sources + self._class_navigation_config = self._collect_class_navigation_config() + self._route_navigation_config: Dict[str, Any] = {} + self._instance_navigation_config: Dict[str, Any] = {} # Configuration self._enable_keyboard_shortcuts = enable_keyboard_shortcuts @@ -110,6 +128,108 @@ def __init__( **kwargs ) + # Navigation configuration utilities + @classmethod + def _collect_class_navigation_config(cls) -> Dict[str, Any]: + """Merge navigation config from the class hierarchy.""" + + config: Dict[str, Any] = {} + for base in reversed(cls.__mro__): + base_config = getattr(base, '__navigation_config__', None) + if base_config: + config.update({ + key: value + for key, value in base_config.items() + if key in cls.NAVIGATION_COMPONENT_KEYS + }) + return config + + @classmethod + def _filter_navigation_config(cls, config: Dict[str, Any]) -> Dict[str, Any]: + """Return only supported navigation keys from a config dict.""" + + if not config: + return {} + return { + key: value + for key, value in config.items() + if key in cls.NAVIGATION_COMPONENT_KEYS + } + + def configure_navigation(self, **components) -> None: + """Set per-instance navigation overrides.""" + + filtered = self._filter_navigation_config(components) + if not filtered: + return + self._instance_navigation_config.update(filtered) + if self.is_mounted: + self.build_navigation_widgets() + self.refresh() + + def set_route_navigation_config(self, config: Dict[str, Any]) -> None: + """Store navigation config provided by the router.""" + + self._route_navigation_config = self._filter_navigation_config(config) + + def _evaluate_navigation_value(self, key: str, value: Any): + """Resolve navigation config entries into concrete controls.""" + + if value is None: + return None + + try: + if inspect.isclass(value): + return value() + + if callable(value): + call_patterns = ( + (self, self.route_info), + (self,), + (self.route_info,), + tuple(), + ) + for args in call_patterns: + try: + return value(*args) + except TypeError: + continue + self.logger.error( + f"Navigation component '{key}' callable has unsupported signature" + ) + return None + + return value + + except Exception as ex: + self.logger.error( + f"Failed to evaluate navigation component '{key}': {ex}", + exc_info=True + ) + return None + + def _resolve_navigation_component( + self, + key: str, + builder: Callable[[], Any] + ): + """Determine the navigation component using override precedence.""" + + sources = ( + self._instance_navigation_config, + self._route_navigation_config, + self._class_navigation_config + ) + + for source in sources: + value = source.get(key, self._MISSING_NAV) + if value is not self._MISSING_NAV: + return self._evaluate_navigation_value(key, value) + + if builder: + return builder() + return None + @property def logger(self): """Logger instance for this page""" @@ -249,26 +369,32 @@ def build_bottom_sheet( def build_navigation_widgets(self): """Build all needed Navigation widgets.""" - # AppBar - self.view.appbar = self.build_app_bar() - - # Bottom AppBar - self.view.bottom_appbar = self.build_bottom_app_bar() - - # Nav Drawer - self.view.drawer = self.build_drawer() - - # Navigation Bar - self.view.navigation_bar = self.build_navigation_bar() + view = self.view + if not view: + return - # Floating Action Button - self.view.floating_action_button = self.build_floating_action_button() - - # Floating Action Button Location - self.view.floating_action_button_location = self.build_floating_action_button_location() - - # End Drawer - self.view.end_drawer = self.build_end_drawer() + view.appbar = self._resolve_navigation_component( + 'app_bar', self.build_app_bar + ) + view.bottom_appbar = self._resolve_navigation_component( + 'bottom_app_bar', self.build_bottom_app_bar + ) + view.drawer = self._resolve_navigation_component( + 'drawer', self.build_drawer + ) + view.navigation_bar = self._resolve_navigation_component( + 'navigation_bar', self.build_navigation_bar + ) + view.floating_action_button = self._resolve_navigation_component( + 'floating_action_button', self.build_floating_action_button + ) + view.floating_action_button_location = self._resolve_navigation_component( + 'floating_action_button_location', + self.build_floating_action_button_location + ) + view.end_drawer = self._resolve_navigation_component( + 'end_drawer', self.build_end_drawer + ) # Lifecycle methods def before_on_init(self): diff --git a/fletx/core/routing/router.py b/fletx/core/routing/router.py index ea6203e..4b6bd0b 100644 --- a/fletx/core/routing/router.py +++ b/fletx/core/routing/router.py @@ -469,6 +469,19 @@ async def _create_component( instance = component_class() if hasattr(instance, 'route_info'): instance.route_info = route_info + + # Apply navigation config from route metadata + navigation_config: Dict[str, Any] = {} + if route_def.meta: + if 'navigation' in route_def.meta and isinstance(route_def.meta['navigation'], dict): + navigation_config.update(route_def.meta['navigation']) + + for key in FletXPage.NAVIGATION_COMPONENT_KEYS: + if key in route_def.meta: + navigation_config[key] = route_def.meta[key] + + if navigation_config: + instance.set_route_navigation_config(navigation_config) return instance elif callable(component_class): diff --git a/fletx/decorators/__init__.py b/fletx/decorators/__init__.py index 8278881..6dbab21 100644 --- a/fletx/decorators/__init__.py +++ b/fletx/decorators/__init__.py @@ -11,6 +11,7 @@ reactive_when, reactive_computed ) from fletx.decorators.controllers import page_controller, with_controller +from fletx.decorators.page import page_config from fletx.decorators.route import register_router from fletx.decorators.effects import use_effect from fletx.core.concurency.worker import worker_task, parallel_task @@ -40,6 +41,7 @@ # Controllers "page_controller", "with_controller", + "page_config", # Routing "register_router", diff --git a/fletx/decorators/page.py b/fletx/decorators/page.py new file mode 100644 index 0000000..43b160f --- /dev/null +++ b/fletx/decorators/page.py @@ -0,0 +1,36 @@ +""" +Page configuration decorators. + +Provides helpers to declaratively configure navigation components on +``FletXPage`` subclasses. +""" + +from typing import Any +from fletx.core.page import FletXPage + + +def page_config(**components: Any): + """Configure navigation components for a ``FletXPage`` subclass. + + Supported keyword arguments match ``FletXPage.NAVIGATION_COMPONENT_KEYS``. + Values can be Flet controls, callables returning controls, or classes that + can be instantiated without arguments. + """ + + invalid = set(components.keys()) - FletXPage.NAVIGATION_COMPONENT_KEYS + if invalid: + formatted = ", ".join(sorted(invalid)) + raise ValueError(f"Unsupported page_config keys: {formatted}") + + def decorator(cls): + if not issubclass(cls, FletXPage): + raise TypeError( + "page_config decorator can only be applied to FletXPage subclasses" + ) + + existing = dict(getattr(cls, "__navigation_config__", {})) + existing.update(FletXPage._filter_navigation_config(components)) + setattr(cls, "__navigation_config__", existing) + return cls + + return decorator diff --git a/tests/test_fletxpage.py b/tests/test_fletxpage.py index 0b14e23..9b867f9 100644 --- a/tests/test_fletxpage.py +++ b/tests/test_fletxpage.py @@ -2,6 +2,8 @@ from unittest.mock import Mock, MagicMock, patch, PropertyMock from fletx.core.page import FletXPage, PageState from fletx.core.controller import FletXController +from fletx.core.routing.models import RouteInfo +from fletx.decorators.page import page_config import flet as ft @@ -13,6 +15,12 @@ def build(self): return ft.Text("Test Page Content") +@page_config(app_bar=lambda page: ft.AppBar(title=ft.Text("Decorated"))) +class DecoratedPage(SamplePage): + """Sample page with declarative navigation config for testing.""" + pass + + class SampleController(FletXController): """Test controller for testing.""" pass @@ -176,4 +184,35 @@ def test_fletxpage_keyboard_shortcuts(mock_dependencies): # Test remove_keyboard_shortcut removed = page.remove_keyboard_shortcut("ctrl+s") assert removed - assert "ctrl+s" not in page._keyboard_shortcuts \ No newline at end of file + assert "ctrl+s" not in page._keyboard_shortcuts + + +def test_page_config_applies_navigation(mock_dependencies): + """Ensure page_config decorator populates navigation components.""" + page = DecoratedPage() + page.route_info = RouteInfo(path="/decorated") + + page._build_page() + + appbar = mock_dependencies['page'].views[-1].appbar + assert isinstance(appbar, ft.AppBar) + assert isinstance(appbar.title, ft.Text) + assert appbar.title.value == "Decorated" + + +def test_route_navigation_overrides_class_config(mock_dependencies): + """Route-level config should override decorator defaults.""" + page = DecoratedPage() + page.route_info = RouteInfo(path="/decorated") + page.set_route_navigation_config({ + 'app_bar': lambda page, info: ft.AppBar( + title=ft.Text(f"Route {info.path if info else ''}") + ) + }) + + page._build_page() + + appbar = mock_dependencies['page'].views[-1].appbar + assert isinstance(appbar, ft.AppBar) + assert appbar.title.value == "Route /decorated" +