Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 145 additions & 19 deletions fletx/core/page.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
*,
Expand Down Expand Up @@ -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
Expand All @@ -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"""
Expand Down Expand Up @@ -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):
Expand Down
13 changes: 13 additions & 0 deletions fletx/core/routing/router.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
2 changes: 2 additions & 0 deletions fletx/decorators/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -40,6 +41,7 @@
# Controllers
"page_controller",
"with_controller",
"page_config",

# Routing
"register_router",
Expand Down
36 changes: 36 additions & 0 deletions fletx/decorators/page.py
Original file line number Diff line number Diff line change
@@ -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
41 changes: 40 additions & 1 deletion tests/test_fletxpage.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -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
Expand Down Expand Up @@ -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
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"