diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..2d129bb --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,27 @@ +name: Publish to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: "3.x" + - name: Install build dependencies + run: | + python -m pip install --upgrade pip + pip install build twine + - name: Build package + run: python -m build + - name: Publish to PyPI + env: + TWINE_USERNAME: __token__ + TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} + run: twine upload dist/* \ No newline at end of file diff --git a/.github/workflows/python-apk.yml b/.github/workflows/python-apk.yml deleted file mode 100644 index 65c2c5f..0000000 --- a/.github/workflows/python-apk.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Python and Flutter Build - -on: - push: - branches: - - main - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v2 - - - name: Set up Python 3.12.1 - uses: actions/setup-python@v2 - with: - python-version: 3.12.1 - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Set up Android SDK - uses: android-actions/setup-android@v2 - - - name: Set up Flutter - uses: subosito/flutter-action@v2 - with: - flutter-version: '3.19.0' # La versión de Flutter que estás utilizando - - - name: Build with Flet - run: | - flet build apk --product FletApp --project FletModel - - - name: Create Release - uses: actions/upload-artifact@v2 - with: - name: package - path: | - build/ - dist/ - bin/ - lib/ - include/ - src/ - if-no-files-found: error diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 82f9275..0000000 --- a/.gitignore +++ /dev/null @@ -1,162 +0,0 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ -cover/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -.pybuilder/ -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -# For a library or package, you might want to ignore these files since the code is -# intended to run in multiple environments; otherwise, check them in: -# .python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# poetry -# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. -# This is especially recommended for binary packages to ensure reproducibility, and is more -# commonly ignored for libraries. -# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control -#poetry.lock - -# pdm -# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. -#pdm.lock -# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it -# in version control. -# https://pdm.fming.dev/latest/usage/project/#working-with-version-control -.pdm.toml -.pdm-python -.pdm-build/ - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ - -# pytype static type analyzer -.pytype/ - -# Cython debug symbols -cython_debug/ - -# PyCharm -# JetBrains specific template is maintained in a separate JetBrains.gitignore that can -# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore -# and can be added to the global gitignore or merged into this file. For a more nuclear -# option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ diff --git a/LICENCE b/LICENCE new file mode 100644 index 0000000..40f1d9f --- /dev/null +++ b/LICENCE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Fasil + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..4fe739a --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,5 @@ +include LICENSE +include README.md +include src/flet_model/py.typed +recursive-include examples *.py +recursive-include tests *.py \ No newline at end of file diff --git a/README.md b/README.md index fd1fc45..5d51d12 100644 --- a/README.md +++ b/README.md @@ -1,117 +1,65 @@ - # Flet Model -This repository contains a simple Flet application demonstrating a basic structure and usage of the Flet framework. +A Model-based router for Flet applications that simplifies the creation of multi-page applications. -## Repository Structure +## Installation -```plaintext -Flet-Model/ -├── assets -│ └── icon.png -├── core -│ └── base.py -├── views -│ └── main_view.py (Add add all flet views here) -├── main.py -└── manifest.json +```bash +pip install flet-model ``` -### File Descriptions - -- **assets/icon.png**: Placeholder for assets like icons and images. -- **core/base.py**: Contains the core classes and logic for the application. -- **views/main_view.py**: Example view demonstrating the usage of the `Model` and `Control` classes. -- **main.py**: Entry point for the Flet application, handling routing and initial setup. -- **manifest.json**: Configuration file specifying application metadata and views. - -## Main Components - -### main.py - -This is the entry point of the application. It loads the configuration from `manifest.json`, dynamically imports the required modules, and sets up the routing and error handling. - -### core/base.py +## Usage -Defines the `Control` and `Model` classes which are used to represent UI components and views in the application. +Here's a simple example of how to use Flet Model: -- **Control**: Wrapper for Flet controls with additional properties. -- **Model**: Base class for creating views. It handles the creation and arrangement of `Control` instances. +```python +import flet as ft +from flet_model import main, Model -### views/main_view.py +class FirstView(Model): + route = 'first' + vertical_alignment = ft.MainAxisAlignment.CENTER + horizontal_alignment = ft.CrossAxisAlignment.CENTER -An example view demonstrating how to use the `Model` and `Control` classes. + appbar = ft.AppBar( + title=ft.Text("First View"), + center_title=True, + bgcolor=ft.Colors.SURFACE) + controls = [ + ft.ElevatedButton("Go to Second Page", on_click="go_second") + ] -```python -#main_view -import flet as ft -from core.base import Model, Control + def go_second(self, e): + self.page.go('first/second') -class MainView(Model): - route = '/' +class SecondView(Model): + route = 'second' + vertical_alignment = ft.MainAxisAlignment.CENTER + horizontal_alignment = ft.CrossAxisAlignment.CENTER appbar = ft.AppBar( - leading=ft.Icon(ft.icons.PALETTE), - leading_width=40, - title=ft.Text("AppBar Example"), - center_title=False, - bgcolor=ft.colors.SURFACE_VARIANT, - actions=[ - ft.IconButton(ft.icons.WB_SUNNY_OUTLINED), - ft.IconButton(ft.icons.FILTER_3), - ft.PopupMenuButton( - items=[ - ft.PopupMenuItem(text="Item 1"), - ] - ), - ], - ) - - name = Control(ft.TextField(label="Name"), sequence=1) - age = Control(ft.TextField(label="Age", keyboard_type=ft.KeyboardType.NUMBER), sequence=2) - submit_button = Control(ft.ElevatedButton(text="Submit", on_click="on_click_submit"), sequence=3) - - def on_click_submit(self, e): - print("Submitted") + title=ft.Text("Second View"), + center_title=True, + bgcolor=ft.Colors.SURFACE) + controls = [ + ft.ElevatedButton("Go to First", on_click="go_first") + ] + + def go_first(self, e): + self.page.go('first') + +# Run the Flet app +ft.app(target=main) ``` ## Features -- can be use for string for assign action in Control class -- - - -## Usage -1. Clone the repository: - ```bash - git clone https://github.com/fasilwdr/Flet-Model.git - cd Flet-Model - ``` - - Create views under views folder and map it into manifest.json - ```json - { - "name": "Flet App", - "short_name": "Flet App", - "version": "1.0.1", - "views": [ - "views/main_view.py", - "views/second_view.py" - ] - } - ``` - -2. Install the required dependencies: - ```bash - pip install flet - ``` - -3. Run the application: - ```bash - flet main.py - ``` - -The application should start, and you will see a simple form with fields for Name and Age, and a Submit button. +- Model-based view definition +- Automatic route handling +- Event binding +- Support for nested routes +- Easy navigation between views ## License -This project is licensed under the MIT License. +This project is licensed under the MIT License. \ No newline at end of file diff --git a/assets/icon.png b/assets/icon.png deleted file mode 100644 index f6c45a7..0000000 Binary files a/assets/icon.png and /dev/null differ diff --git a/assets/logo_128.png b/assets/logo_128.png deleted file mode 100644 index 22a177a..0000000 Binary files a/assets/logo_128.png and /dev/null differ diff --git a/assets/logo_512.png b/assets/logo_512.png deleted file mode 100644 index fa41075..0000000 Binary files a/assets/logo_512.png and /dev/null differ diff --git a/core/__init__.py b/core/__init__.py deleted file mode 100644 index d740490..0000000 --- a/core/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from . import base -from . import controls \ No newline at end of file diff --git a/core/base.py b/core/base.py deleted file mode 100644 index db6715e..0000000 --- a/core/base.py +++ /dev/null @@ -1,108 +0,0 @@ -# core/base.py -import flet as ft -import inspect - - -class Model: - route = None - controls = [] - back_route = None - appbar = None - bottom_appbar = None - auto_scroll = None - bgcolor = None - drawer = None - end_drawer = None - fullscreen_dialog = None - floating_action_button = None - floating_action_button_location = None - navigation_bar = None - horizontal_alignment = ft.CrossAxisAlignment.START - on_scroll_interval = 10 - on_keyboard_event = None - padding = 10 - scroll = None - on_scroll = None - spacing = 10 - vertical_alignment = ft.MainAxisAlignment.START - overlay_controls = [] - - def __init__(self, page): - self.page = page - - def on_view_pop(self, e): - self.page.go(self.back_route) - - def init(self): - pass - - def post_init(self): - pass - - def create_view(self): - controls = self.controls - if self.overlay_controls: - for overlay in self.overlay_controls: - self.page.overlay.append(overlay) - if self.back_route: - self.page.on_view_pop = self.on_view_pop - if self.on_keyboard_event: - self.page.on_keyboard_event = self.on_keyboard_event - - self.init() - - #Add Other Controls to bind event handlers - controls_to_bind = controls + self.overlay_controls + [self.appbar, self.bottom_appbar, self.drawer, self.navigation_bar] - # Dynamically bind event handlers - self.bind_event_handlers(controls_to_bind) - - view = ft.View( - route=self.route, - controls=controls, - appbar=self.appbar, - bottom_appbar=self.bottom_appbar, - auto_scroll=self.auto_scroll, - bgcolor=self.bgcolor, - drawer=self.drawer, - end_drawer=self.end_drawer, - fullscreen_dialog=self.fullscreen_dialog, - floating_action_button=self.floating_action_button, - floating_action_button_location=self.floating_action_button_location, - horizontal_alignment=self.horizontal_alignment, - on_scroll_interval=self.on_scroll_interval, - padding=self.padding, - scroll=self.scroll, - spacing=self.spacing, - vertical_alignment=self.vertical_alignment, - navigation_bar=self.navigation_bar, - on_scroll=self.on_scroll, - ) - self.post_init() - return view - - def bind_event_handlers(self, controls): - # Event handler attributes to look for - event_attrs = ['on_click', 'on_hover', 'on_long_press', 'on_change', 'on_dismiss'] - - for control in controls: - for attr in event_attrs: - if hasattr(control, attr): - handler = getattr(control, attr) - if isinstance(handler, str) and hasattr(self, handler): - # Bind the event handler - setattr(control, attr, getattr(self, handler)) - - # If the control has nested controls, bind their event handlers too - if hasattr(control, 'controls'): - self.bind_event_handlers(control.controls) - if hasattr(control, 'content'): - self.bind_event_handlers([control.content]) - - -def view_model(page): - # Scan through subclasses of Model to find a matching route - for cls in Model.__subclasses__(): - if cls.route == page.route: - return cls(page).create_view() # Pass the page object to the class - # If no matching class is found - raise ValueError("No view available for the given route") diff --git a/core/controls.py b/core/controls.py deleted file mode 100644 index e42c615..0000000 --- a/core/controls.py +++ /dev/null @@ -1,53 +0,0 @@ -import flet as ft -from datetime import datetime, date - - -def UserError(page, text): - snackbar = ft.SnackBar(content=ft.Text(text, color=ft.colors.WHITE, text_align="center"), open=True, bgcolor=ft.colors.RED) - page.show_snack_bar(snackbar) - - -def UserInfo(page, text): - snackbar = ft.SnackBar(content=ft.Text(text, color=ft.colors.WHITE, text_align="center"), open=True, bgcolor=ft.colors.BLUE) - page.show_snack_bar(snackbar) - - -def UserWarning(page, text): - snackbar = ft.SnackBar(content=ft.Text(text, color=ft.colors.BLACK, text_align="center"), open=True, bgcolor=ft.colors.YELLOW) - page.show_snack_bar(snackbar) - - -def get_formated_date(date_string, format): - date_object = datetime.strptime(date_string, "%Y-%m-%dT%H:%M:%S.%f") - formatted_date = date_object.strftime(format) - return formatted_date - - -class DateField(ft.Container): - def __init__(self, label, value='', col=None, format='%d-%m-%Y'): - super().__init__() - self.value = value - self.format = format - self.date_picker = ft.DatePicker( - on_change=self.change_date, - current_date=datetime.strptime(self.value, '%d-%m-%Y').date() if self.value else None, - first_date=date(2023, 10, 1), - ) - self.content = ft.Row(controls=[ - ft.Text(label), - ft.IconButton(ft.icons.DATE_RANGE, on_click=self.pick_date), - ft.Text(self.value) - ]) - self.col = col - # self.border = ft.InputBorder.NONE - self.padding = 5 - self.bgcolor = ft.colors.SURFACE - - def pick_date(self, e): - self.page.overlay.append(self.date_picker) - self.page.update() - self.date_picker.pick_date() - def change_date(self, e): - self.value = get_formated_date(e.data, self.format) - self.content.controls[-1].value = self.value - self.content.controls[-1].update() \ No newline at end of file diff --git a/core/db.py b/core/db.py deleted file mode 100644 index 7b22fc6..0000000 --- a/core/db.py +++ /dev/null @@ -1,47 +0,0 @@ -import sqlite3 - - -def execute_query(query, params=None): - """ - Execute a given query on the SQLite database. - - Parameters: - - query (str): The SQL query to be executed. - - params (tuple, optional): The parameters to be used with the query. - - Returns: - - list: The fetched results from the query if it is a SELECT query. - - None: If the query is not a SELECT query. - """ - conn = sqlite3.connect('assets/data.db') - cursor = conn.cursor() - - try: - if params: - cursor.execute(query, params) - else: - cursor.execute(query) - - if query.strip().lower().startswith('select'): - results = cursor.fetchall() - conn.close() - return results - else: - conn.commit() - except sqlite3.Error as e: - print(f"An error occurred: {e}") - finally: - conn.close() - - -# Create table query -create_table_query = ''' -CREATE TABLE IF NOT EXISTS stb ( - id INTEGER PRIMARY KEY, - odoo_id INTEGER, - name VARCHAR(255) -); -''' - -# Execute the create table query -execute_query(create_table_query) diff --git a/main.py b/main.py deleted file mode 100644 index a9f16bb..0000000 --- a/main.py +++ /dev/null @@ -1,47 +0,0 @@ -import flet as ft -from core.base import view_model -import json - -pattern = r'^[A-Z0-9!@#$%^&*()-_=+\\\[\]{}|;:\'",.<>/?]*$' - -# Load and parse the config.json -with open('manifest.json', 'r') as file: - manifest = json.load(file) - -# Dynamically import the module -for addon_path in manifest['views']: - module_name = addon_path.split('.')[0].replace('/', '.') - __import__(module_name) - - -def main(page: ft.Page): - def on_error_page(e): - if page.route != '/': - page.go('/') - else: - page.client_storage.clear() - page.update() - page.go('/') - - def on_route_change(e): - page.views.clear() - # Use view_model from the dynamically imported modules - page.views.append(view_model(page)) - page.update() - page.data = { - 'manifest': manifest - } - page.title = manifest.get('name', False) or 'Flet App' - page.on_route_change = on_route_change - # page.on_error = on_error_page - if manifest.get('default_theme_mode'): - page.theme_mode = manifest.get('default_theme_mode') - if manifest.get('color_scheme_seed'): - page.theme = ft.theme.Theme(color_scheme_seed=manifest.get('color_scheme_seed')) - page.scroll = ft.ScrollMode.AUTO - if not page.client_storage.get("manifest"): - page.route = '/login' - page.go(page.route) - - -ft.app(target=main, assets_dir='assets') diff --git a/manifest.json b/manifest.json deleted file mode 100644 index 1ddd2ef..0000000 --- a/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Flet App", - "short_name": "Flet App", - "version": "1.0.1", - "default_theme_mode": "light", - "views": [ - "views/main_view.py", - "views/second_view.py" - ] -} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..f6776a9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,97 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "flet-model" +version = "0.1.0" +description = "A Model-based router for Flet applications that simplifies the creation of multi-page applications" +readme = "README.md" +authors = [{ name = "Fasil", email = "fasilwdr@hotmail.com" }] +license = { file = "LICENSE" } +classifiers = [ + "Development Status :: 3 :: Alpha", + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Software Development :: User Interfaces", +] +keywords = ["flet", "router", "model", "navigation", "gui", "mvc", "ui", "framework"] +dependencies = [ + "flet>=0.10.0", +] +requires-python = ">=3.7" + +[project.optional-dependencies] +dev = [ + "pytest>=7.0.0", + "pytest-cov", # for coverage reporting + "black", # for formatting + "isort", # for import sorting + "flake8", # for linting + "mypy", # for type checking + "build", # for building package + "twine", # for publishing + "sphinx", # for documentation + "sphinx-rtd-theme", # for documentation theme +] + +[project.urls] +Homepage = "https://github.com/fasilwdr/Flet-Model" +Documentation = "https://github.com/fasilwdr/Flet-Model#readme" +Repository = "https://github.com/fasilwdr/Flet-Model.git" +Issues = "https://github.com/fasilwdr/Flet-Model/issues" +Changelog = "https://github.com/fasilwdr/Flet-Model/releases" + +[tool.black] +line-length = 88 +target-version = ['py37'] +include = '\.pyi?$' +extend-exclude = ''' +# A regex preceded with ^/ will apply only to files and directories +# in the root of the project. +^/docs/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +line_length = 88 +known_first_party = ["flet_model"] + +[tool.pytest.ini_options] +minversion = "7.0" +addopts = "-ra -q --cov=flet_model --cov-report=term-missing" +testpaths = [ + "tests", +] +python_files = ["test_*.py"] +pythonpath = [ + "src" +] + +[tool.mypy] +python_version = "3.7" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_optional = true \ No newline at end of file diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 8d97ed6..0000000 --- a/requirements.txt +++ /dev/null @@ -1 +0,0 @@ -flet==0.22.0 \ No newline at end of file diff --git a/src/flet_model/__init__.py b/src/flet_model/__init__.py new file mode 100644 index 0000000..438a8ca --- /dev/null +++ b/src/flet_model/__init__.py @@ -0,0 +1,6 @@ +from .model import Model +from .router import Router + +__version__ = "0.1.0" + +__all__ = ["Model", "Router"] \ No newline at end of file diff --git a/src/flet_model/model.py b/src/flet_model/model.py new file mode 100644 index 0000000..90d8933 --- /dev/null +++ b/src/flet_model/model.py @@ -0,0 +1,172 @@ +from typing import List, Optional, Callable, Any, Union, Dict +import flet as ft +from functools import lru_cache +import weakref + + +class Model: + """Base class for creating view models in Flet applications.""" + + route: str = None + controls: Union[List[ft.Control], Callable[[], List[ft.Control]]] = [] + appbar: Optional[ft.AppBar] = None + bottom_appbar: Optional[ft.BottomAppBar] = None + auto_scroll: Optional[bool] = None + bgcolor: Optional[str] = None + drawer: Optional[ft.NavigationDrawer] = None + end_drawer: Optional[ft.NavigationDrawer] = None + fullscreen_dialog: Optional[bool] = None + floating_action_button: Optional[ft.FloatingActionButton] = None + floating_action_button_location: Optional[str] = None + navigation_bar: Optional[ft.NavigationBar] = None + horizontal_alignment: ft.CrossAxisAlignment = ft.CrossAxisAlignment.START + on_scroll_interval: int = 10 + on_keyboard_event: Optional[Callable] = None + padding: int = 10 + scroll: Optional[bool] = None + on_scroll: Optional[Callable] = None + spacing: int = 10 + vertical_alignment: ft.MainAxisAlignment = ft.MainAxisAlignment.START + overlay_controls: List[ft.Control] = [] + + # Class-level cache for event handlers + _event_handler_cache: Dict[str, Callable] = {} + + def __init__(self, page: ft.Page): + """Initialize the model with a Flet page instance.""" + self.page = page + self.view: Optional[ft.View] = None + self._control_cache = {} + + @lru_cache(maxsize=32) + def get_cached_controls(self) -> List[ft.Control]: + """Cache and return controls if they're generated by a callable.""" + if callable(self.controls): + return self.controls() + return self.controls + + def init(self) -> None: + """Initialize the model. Override this method for setup logic.""" + pass + + def post_init(self) -> None: + """Post-initialization hook. Override for logic after view creation.""" + pass + + def create_view(self) -> ft.View: + """Create and return a Flet View instance based on model properties.""" + # Use cached controls + controls = self.get_cached_controls() + + # Handle overlay controls efficiently + if self.overlay_controls: + self.page.overlay.extend(self.overlay_controls) + + # Efficient event binding + if self.on_keyboard_event: + self.page.on_keyboard_event = self.on_keyboard_event + if self.on_scroll: + self.page.on_scroll = self.on_scroll + + # Run init in thread + self.page.run_thread(self.init) + + # Collect all controls that need event binding + controls_to_bind = [] + controls_to_bind.extend(controls) + controls_to_bind.extend(self.overlay_controls) + if self.floating_action_button: + controls_to_bind.append(self.floating_action_button) + if self.bottom_appbar: + controls_to_bind.append(self.bottom_appbar) + + self.bind_event_handlers(controls_to_bind) + + # Create view with all properties + self.view = ft.View( + route=self.route, + controls=controls, + appbar=self.appbar, + bottom_appbar=self.bottom_appbar, + auto_scroll=self.auto_scroll, + bgcolor=self.bgcolor, + drawer=self.drawer, + end_drawer=self.end_drawer, + fullscreen_dialog=self.fullscreen_dialog, + floating_action_button=self.floating_action_button, + floating_action_button_location=self.floating_action_button_location, + horizontal_alignment=self.horizontal_alignment, + on_scroll_interval=self.on_scroll_interval, + padding=self.padding, + scroll=self.scroll, + spacing=self.spacing, + vertical_alignment=self.vertical_alignment, + navigation_bar=self.navigation_bar, + on_scroll=self.on_scroll, + ) + + self.page.run_thread(self.post_init) + return self.view + + def update(self) -> None: + """Update the view with current model properties.""" + if not self.view: + return + + # Batch update all properties + updates = { + 'controls': self.get_cached_controls(), + 'appbar': self.appbar, + 'bottom_appbar': self.bottom_appbar, + 'auto_scroll': self.auto_scroll, + 'bgcolor': self.bgcolor, + 'drawer': self.drawer, + 'end_drawer': self.end_drawer, + 'fullscreen_dialog': self.fullscreen_dialog, + 'floating_action_button': self.floating_action_button, + 'floating_action_button_location': self.floating_action_button_location, + 'horizontal_alignment': self.horizontal_alignment, + 'on_scroll_interval': self.on_scroll_interval, + 'padding': self.padding, + 'scroll': self.scroll, + 'spacing': self.spacing, + 'vertical_alignment': self.vertical_alignment, + 'navigation_bar': self.navigation_bar, + } + + for attr, value in updates.items(): + if hasattr(self.view, attr): + setattr(self.view, attr, value) + + self.view.update() + + def bind_event_handlers(self, controls: List[ft.Control]) -> None: + """Recursively bind event handlers to controls with caching.""" + event_attrs = ('on_click', 'on_hover', 'on_long_press', 'on_change', 'on_dismiss') + + for control in controls: + if not control: + continue + + control_id = id(control) + if control_id in self._control_cache: + continue + + self._control_cache[control_id] = True + + for attr in event_attrs: + if hasattr(control, attr): + handler = getattr(control, attr) + if isinstance(handler, str) and hasattr(self, handler): + # Cache the handler + if handler not in self._event_handler_cache: + self._event_handler_cache[handler] = getattr(self, handler) + setattr(control, attr, self._event_handler_cache[handler]) + + # Recursively bind nested controls + if hasattr(control, 'controls') and control.controls: + self.bind_event_handlers(control.controls) + if hasattr(control, 'content') and control.content: + self.bind_event_handlers([control.content]) + if hasattr(control, 'header') and control.header: + self.bind_event_handlers([control.header]) \ No newline at end of file diff --git a/src/flet_model/router.py b/src/flet_model/router.py new file mode 100644 index 0000000..6f41fca --- /dev/null +++ b/src/flet_model/router.py @@ -0,0 +1,86 @@ +from typing import Dict, Optional, Type +import flet as ft +from .model import Model + + +class Router: + """Router class for handling navigation in Flet applications.""" + + _instance: Optional['Router'] = None + _routes: Dict[str, Model] = {} + _page: Optional[ft.Page] = None + _view_cache: Dict[str, ft.View] = {} # Cache for views + + def __new__(cls, *args, **kwargs): + if cls._instance is None: + cls._instance = super(Router, cls).__new__(cls) + return cls._instance + + def __init__(self, *route_maps: Dict[str, Model]): + """Initialize the router with route mappings.""" + if not self._routes: + self._routes = {} + for route_map in route_maps: + self._routes.update(route_map) + + if route_maps and list(route_maps[0].values()): + first_model = list(route_maps[0].values())[0] + self._page = first_model.page + self._setup_routing() + + def _setup_routing(self) -> None: + """Set up route handling and initialize default route.""" + if not self._page: + return + + self._page.on_route_change = self._handle_route_change + self._page.on_view_pop = self._handle_view_pop + + if not self._page.route or self._page.route == '/': + default_route = next(iter(self._routes.keys())) + self._page.route = default_route + self._page.go(default_route) + + def _handle_route_change(self, e: ft.RouteChangeEvent) -> None: + """Handle route changes and update view stack with caching.""" + route_parts = self._page.route.lstrip('/').split('/') + self._page.views.clear() + current_route = '' + + for part in route_parts: + if part: + current_route = f"{current_route}/{part}" if current_route else part + if part in self._routes: + # Check view cache first + if part not in self._view_cache: + self._view_cache[part] = self._routes[part].create_view() + self._page.views.append(self._view_cache[part]) + + self._page.update() + + def _handle_view_pop(self, e: ft.ViewPopEvent) -> None: + """Handle back navigation.""" + if len(self._page.views) > 1: + self._page.views.pop() + routes = self._page.route.split('/') + routes.pop() + self._page.go('/'.join(routes)) + self._page.update() + + @classmethod + def register_route(cls, route: str, model_class: Type[Model]) -> None: + """Register a new route with its corresponding model class.""" + if cls._instance and cls._instance._page: + cls._instance._routes[route] = model_class(cls._instance._page) + # Clear view cache for this route + if route in cls._instance._view_cache: + del cls._instance._view_cache[route] + + @classmethod + def get_current_model(cls) -> Optional[Model]: + """Get the model instance for the current route.""" + if not (cls._instance and cls._instance._page and cls._instance._page.route): + return None + + current_route = cls._instance._page.route.split('/')[-1] + return cls._instance._routes.get(current_route) \ No newline at end of file diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..e86aaf4 --- /dev/null +++ b/tests/test_model.py @@ -0,0 +1,31 @@ +import pytest +import flet as ft +from flet_model import Model + + +def test_model_initialization(): + page = ft.Page() + + class TestModel(Model): + route = "test" + controls = [ft.Text("Test")] + + model = TestModel(page) + assert model.route == "test" + assert len(model.controls) == 1 + + +def test_model_view_creation(): + page = ft.Page() + + class TestModel(Model): + route = "test" + controls = [ft.Text("Test")] + appbar = ft.AppBar(title=ft.Text("Test")) + + model = TestModel(page) + view = model.create_view() + + assert view.route == "test" + assert len(view.controls) == 1 + assert isinstance(view.appbar, ft.AppBar) \ No newline at end of file diff --git a/views/main_view.py b/views/main_view.py deleted file mode 100644 index a98aa6f..0000000 --- a/views/main_view.py +++ /dev/null @@ -1,163 +0,0 @@ -#main_view -import flet as ft -from core.base import Model -from core.controls import UserError, UserInfo, UserWarning -import datetime - - -class MainView(Model): - route = '/' - vertical_alignment = ft.MainAxisAlignment.CENTER - horizontal_alignment = ft.CrossAxisAlignment.CENTER - - appbar = ft.AppBar( - leading=ft.IconButton(ft.icons.PALETTE, on_click="check"), - leading_width=40, - title=ft.Text("Main View"), - center_title=True, - bgcolor=ft.colors.SURFACE_VARIANT) - - navigation_bar = ft.NavigationBar( - destinations=[ - ft.NavigationDestination(icon=ft.icons.EXPLORE, label="Explore"), - ft.NavigationDestination(icon=ft.icons.COMMUTE, label="Commute"), - ft.NavigationDestination( - icon=ft.icons.BOOKMARK_BORDER, - selected_icon=ft.icons.BOOKMARK, - label="Explore", - ), - ] - ) - drawer = ft.NavigationDrawer( - controls=[ - ft.Container(height=12), - ft.NavigationDrawerDestination( - label="Item 1", - icon=ft.icons.DOOR_BACK_DOOR_OUTLINED, - selected_icon_content=ft.Icon(ft.icons.DOOR_BACK_DOOR), - ), - ft.Divider(thickness=2), - ft.NavigationDrawerDestination( - icon_content=ft.Icon(ft.icons.MAIL_OUTLINED), - label="Item 2", - selected_icon=ft.icons.MAIL, - ), - ft.NavigationDrawerDestination( - icon_content=ft.Icon(ft.icons.PHONE_OUTLINED), - label="Item 3", - selected_icon=ft.icons.PHONE, - ), - ], - ) - - # Banner - def close_banner(e): - e.control.page.close_banner() - print("banner closed") - - banner = ft.Banner( - bgcolor=ft.colors.AMBER_100, - leading=ft.Icon(ft.icons.WARNING_AMBER_ROUNDED, color=ft.colors.AMBER, size=40), - content=ft.Text( - "Oops, there were some errors while trying to delete the file. What would you like me to do?" - ), - actions=[ - ft.TextButton("Retry", on_click=close_banner), - ft.TextButton("Ignore", on_click=close_banner), - ft.TextButton("Cancel", on_click=close_banner), - ], - ) - - # AlertDialog - dlg = ft.AlertDialog( - title=ft.Text("Hello, you!") - ) - - dlg_modal = ft.AlertDialog( - modal=True, - title=ft.Text("Please confirm"), - content=ft.Text("Do you really want to delete all those files?"), - actions=[ - ft.TextButton("Yes", on_click=lambda e: e.control.page.close_dialog()), - ft.TextButton("No", on_click=lambda e: e.control.page.close_dialog()), - ], - actions_alignment=ft.MainAxisAlignment.END, - on_dismiss=lambda e: print("Modal dialog dismissed!"), - ) - - actions = ft.Dropdown( - options=[ - ft.dropdown.Option("Open Drawer"), - ft.dropdown.Option("Show Banner"), - # ft.dropdown.Option("Open DatePicker"), - ft.dropdown.Option("Show Dialog"), - ft.dropdown.Option("Show Dialog Modal"), - ft.dropdown.Option("Show BottomSheet"), - ft.dropdown.Option("Check UserError (SnackBar)"), - ft.dropdown.Option("Check UserInfo (SnackBar)"), - ft.dropdown.Option("Check UserWarning (SnackBar)"), - ft.dropdown.Option("Go to Second Page"), - ], - ) - # BottomSheet - bottom_sheet = ft.BottomSheet( - ft.Container( - ft.Column( - [ - ft.Text("This is sheet's content!"), - ft.ElevatedButton("Close bottom sheet", on_click=lambda e: e.control.page.close_bottom_sheet()), - ], - tight=True, - ), - padding=10, - ), - open=True, - on_dismiss=lambda e: print("Bottom Sheet dismissed!"), - ) - - # DatePicker - def change_date(self, e): - print(f"Date picker changed, value is {self.date_picker.value}") - - def date_picker_dismissed(self, e): - print(f"Date picker dismissed, value is {self.date_picker.value}") - - # date_picker = ft.DatePicker( - # on_change="change_date", - # on_dismiss="date_picker_dismissed", - # first_date=datetime.datetime(2023, 10, 1), - # last_date=datetime.datetime(2024, 10, 1), - # ) - # - # overlay_controls = [date_picker] - - controls = [ - actions, - ft.ElevatedButton("Go", on_click='on_click_check_button') - ] - - def on_click_check_button(self, e): - if self.actions.value == 'Check UserError (SnackBar)': - return UserError(self.page, "Error Message") - elif self.actions.value == 'Check UserInfo (SnackBar)': - return UserInfo(self.page, "Info Message") - elif self.actions.value == 'Check UserWarning (SnackBar)': - return UserWarning(self.page, "Warning Message") - if self.actions.value == 'Open Drawer': - self.page.show_drawer(self.drawer) - elif self.actions.value == 'Go to Second Page': - self.page.go('/second') - elif self.actions.value == 'Open DatePicker': - self.date_picker.pick_date() - elif self.actions.value == 'Show Banner': - self.page.show_banner(self.banner) - elif self.actions.value == 'Show Dialog': - self.page.show_dialog(self.dlg) - elif self.actions.value == 'Show Dialog Modal': - self.page.show_dialog(self.dlg_modal) - elif self.actions.value == 'Show BottomSheet': - self.page.show_bottom_sheet(self.bottom_sheet) - - - - diff --git a/views/second_view.py b/views/second_view.py deleted file mode 100644 index cb43c38..0000000 --- a/views/second_view.py +++ /dev/null @@ -1,27 +0,0 @@ -#main_view -import flet as ft -from core.base import Model - - -class SecondView(Model): - route = '/second' - back_route = '/' - - vertical_alignment = ft.MainAxisAlignment.CENTER - horizontal_alignment = ft.CrossAxisAlignment.CENTER - - # def init(self): - # print("init") - - appbar = ft.AppBar( - leading=ft.Icon(ft.icons.PALETTE), - leading_width=40, - title=ft.Text("Second View"), - center_title=True, - bgcolor=ft.colors.SURFACE_VARIANT) - - controls = [ - ft.Text("Second Page"), - ft.ElevatedButton("Go Home", on_click=lambda e: e.control.page.go('/')) - ] -