From 016cc20cf2baf47f919a3ea03b40aa106c959988 Mon Sep 17 00:00:00 2001 From: Christian Staudt <875194+clstaudt@users.noreply.github.com> Date: Sun, 11 Sep 2022 11:51:15 +0200 Subject: [PATCH] Dev flet gui (#53) * minimal test * experimentation with flet * minor * MVC example * MVC example * WIP: UI components * removed requirement fints to make pyinstaller work * rename app main file * demo content * displaying demo objects * WIP: desktop app layout * WIP: app layout * WIP: app layout * WIP: app layout * WIP: invoicing page * prepare for merge --- .vscode/settings.json | 3 +- app/Tuttle.py | 302 ++++++++++++++++++ app/components/menu_layout.py | 216 +++++++++++++ app/components/navigation.py | 76 +++++ app/components/routing.py | 41 +++ app/components/select_time_tracking.py | 67 ++++ app/components/view_contact.py | 124 ++++++++ app/components/view_contract.py | 55 ++++ app/components/view_user.py | 108 +++++++ app/examples/alert_dialog.py | 44 +++ app/examples/banner.py | 30 ++ app/examples/card.py | 55 ++++ app/examples/circle_avatar.py | 44 +++ app/examples/dropdown.py | 20 ++ app/examples/file_upload.py | 41 +++ app/examples/footer.py | 21 ++ app/examples/icon_browser.py | 147 +++++++++ app/examples/list_tile.py | 80 +++++ app/examples/list_view.py | 26 ++ app/examples/navigation_rail.py | 56 ++++ app/examples/responsive_menu_layout.py | 403 +++++++++++++++++++++++++ app/layout.py | 219 ++++++++++++++ app/views.py | 224 ++++++++++++++ requirements.txt | 2 +- requirements_dev.txt | 1 + tuttle/banking.py | 47 +-- tuttle/controller.py | 45 ++- tuttle/model.py | 15 + tuttle_tests/conftest.py | 16 + tuttle_tests/demo.py | 173 +++++++++++ 30 files changed, 2671 insertions(+), 30 deletions(-) create mode 100644 app/Tuttle.py create mode 100644 app/components/menu_layout.py create mode 100644 app/components/navigation.py create mode 100644 app/components/routing.py create mode 100644 app/components/select_time_tracking.py create mode 100644 app/components/view_contact.py create mode 100644 app/components/view_contract.py create mode 100644 app/components/view_user.py create mode 100644 app/examples/alert_dialog.py create mode 100644 app/examples/banner.py create mode 100644 app/examples/card.py create mode 100644 app/examples/circle_avatar.py create mode 100644 app/examples/dropdown.py create mode 100644 app/examples/file_upload.py create mode 100644 app/examples/footer.py create mode 100644 app/examples/icon_browser.py create mode 100644 app/examples/list_tile.py create mode 100644 app/examples/list_view.py create mode 100644 app/examples/navigation_rail.py create mode 100644 app/examples/responsive_menu_layout.py create mode 100644 app/layout.py create mode 100644 app/views.py create mode 100644 tuttle_tests/demo.py diff --git a/.vscode/settings.json b/.vscode/settings.json index 0cd29116..062eac7a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "python.testing.unittestEnabled": false, - "python.testing.pytestEnabled": true + "python.testing.pytestEnabled": true, + "python.formatting.provider": "black" } diff --git a/app/Tuttle.py b/app/Tuttle.py new file mode 100644 index 00000000..6e714c84 --- /dev/null +++ b/app/Tuttle.py @@ -0,0 +1,302 @@ +from loguru import logger + +import flet +from flet import ( + Page, + Row, + Column, + Container, + Text, + Card, + NavigationRailDestination, + UserControl, + ElevatedButton, + TextButton, + Icon, + Dropdown, + dropdown, +) +from flet import icons, colors + +from layout import DesktopAppLayout + +import views +from views import ( + ContactView, + ContactView2, +) + +from tuttle.controller import Controller +from tuttle.model import ( + Contact, + Contract, + Project, + Client, +) + +from tuttle_tests import demo + + +class App: + def __init__( + self, + controller: Controller, + page: Page, + ): + self.con = controller + self.page = page + + +class AppPage(UserControl): + def __init__( + self, + app: App, + ): + super().__init__() + self.app = app + + def build(self): + self.main_column = Column( + scroll="auto", + ) + # self.view = Row([self.main_column]) + + return self.main_column + + def update_content(self): + pass + + +class DemoPage(AppPage): + def __init__( + self, + app: App, + ): + super().__init__(app) + + def update(self): + super().update() + + def add_demo_data(self, event): + """Install the demo data on button click.""" + demo.add_demo_data(self.app.con) + self.main_column.controls.clear() + self.main_column.controls.append( + Text("Demo data installed ☑️"), + ) + self.update() + + def build(self): + self.main_column = Column( + [ + ElevatedButton( + "Install demo data", + icon=icons.TOYS, + on_click=self.add_demo_data, + ), + ], + ) + return self.main_column + + +class ContactsPage(AppPage): + def __init__( + self, + app: App, + ): + super().__init__(app) + + def update(self): + super().update() + + def update_content(self): + super().update_content() + self.main_column.controls.clear() + + contacts = self.app.con.query(Contact) + + for contact in contacts: + self.main_column.controls.append( + views.make_contact_view(contact), + ) + self.update() + + +class ContractsPage(AppPage): + def __init__( + self, + app: App, + ): + super().__init__(app) + + def update(self): + super().update() + + def update_content(self): + super().update_content() + self.main_column.controls.clear() + + contracts = self.app.con.query(Contract) + + for contract in contracts: + self.main_column.controls.append( + # TODO: replace with view class + views.make_contract_view(contract) + ) + self.update() + + +class ProjectsPage(AppPage): + def __init__( + self, + app: App, + ): + super().__init__(app) + + def update(self): + super().update() + + def update_content(self): + super().update_content() + self.main_column.controls.clear() + + projects = self.app.con.query(Project) + + for project in projects: + self.main_column.controls.append( + # TODO: replace with view class + views.make_project_view(project) + ) + self.update() + + +class InvoicingPage(AppPage): + def __init__( + self, + app: App, + ): + super().__init__(app) + + def update(self): + super().update() + + def update_content(self): + super().update_content() + + self.main_column.controls.clear() + + projects = self.app.con.query(Project) + + project_select = Dropdown( + label="Project", + hint_text="Select the project", + options=[dropdown.Option(project.title) for project in projects], + autofocus=True, + ) + + self.main_column.controls.append( + Row( + [ + project_select, + ] + ) + ) + self.update() + + +def main(page: Page): + + con = Controller( + in_memory=True, + verbose=False, + ) + + app = App( + controller=con, + page=page, + ) + + pages = [ + ( + NavigationRailDestination( + icon=icons.TOYS_OUTLINED, + selected_icon=icons.TOYS, + label="Demo", + ), + DemoPage(app), + ), + ( + NavigationRailDestination( + icon=icons.SPEED_OUTLINED, + selected_icon=icons.SPEED, + label="Dashboard", + ), + AppPage(app), + ), + ( + NavigationRailDestination( + icon=icons.WORK, + label="Projects", + ), + ProjectsPage(app), + ), + ( + NavigationRailDestination( + icon=icons.DATE_RANGE, + label="Time", + ), + AppPage(app), + ), + ( + NavigationRailDestination( + icon=icons.CONTACT_MAIL, + label="Contacts", + ), + ContactsPage(app), + ), + ( + NavigationRailDestination( + icon=icons.HANDSHAKE, + label="Clients", + ), + AppPage(app), + ), + ( + NavigationRailDestination( + icon=icons.HISTORY_EDU, + label="Contracts", + ), + ContractsPage(app), + ), + ( + NavigationRailDestination( + icon=icons.OUTGOING_MAIL, + label="Invocing", + ), + InvoicingPage(app), + ), + ( + NavigationRailDestination( + icon=icons.SETTINGS, + label_content=Text("Settings"), + ), + AppPage(app), + ), + ] + + layout = DesktopAppLayout( + page=page, + pages=pages, + title="Tuttle", + window_size=(1280, 720), + ) + + page.add( + layout, + ) + + +if __name__ == "__main__": + flet.app( + target=main, + ) diff --git a/app/components/menu_layout.py b/app/components/menu_layout.py new file mode 100644 index 00000000..04de2f58 --- /dev/null +++ b/app/components/menu_layout.py @@ -0,0 +1,216 @@ +from copy import deepcopy + +import flet +from flet import ( + AppBar, + Column, + Row, + Container, + IconButton, + Icon, + NavigationRail, + NavigationRailDestination, + Page, + Text, + Card, + Divider, +) +from flet import colors, icons + + +class MenuLayout(Row): + """A desktop app layout with a menu on the left.""" + + def __init__( + self, + title, + page, + pages, + *args, + window_size=(800, 600), + **kwargs, + ): + super().__init__(*args, **kwargs) + + self.page = page + self.pages = pages + + self.expand = True + + self.navigation_items = [navigation_item for navigation_item, _ in pages] + self.navigation_rail = self.build_navigation_rail() + self.update_destinations() + self._menu_extended = True + self.navigation_rail.extended = True + + self.menu_panel = Row( + controls=[ + self.navigation_rail, + ], + spacing=0, + tight=True, + ) + + page_contents = [page_content for _, page_content in pages] + self.content_area = Column(page_contents, expand=True) + + self._was_portrait = self.is_portrait() + self._panel_visible = self.is_landscape() + + self.set_content() + + self._change_displayed_page() + + self.page.on_resize = self.handle_resize + + self.page.appbar = self.create_appbar() + + self.window_size = window_size + self.page.window_width, self.page.window_height = self.window_size + + self.page.title = title + + def select_page(self, page_number): + self.navigation_rail.selected_index = page_number + self._change_displayed_page() + + def _navigation_change(self, e): + self._change_displayed_page() + self.page.update() + + def _change_displayed_page(self): + page_number = self.navigation_rail.selected_index + for i, content_page in enumerate(self.content_area.controls): + content_page.visible = page_number == i + + def build_navigation_rail(self): + return NavigationRail( + selected_index=0, + label_type="none", + on_change=self._navigation_change, + # bgcolor=colors.SURFACE_VARIANT, + ) + + def update_destinations(self): + self.navigation_rail.destinations = self.navigation_items + self.navigation_rail.label_type = "all" + + def handle_resize(self, e): + pass + + def set_content(self): + self.controls = [self.menu_panel, self.content_area] + self.update_destinations() + self.navigation_rail.extended = self._menu_extended + self.menu_panel.visible = self._panel_visible + + def is_portrait(self) -> bool: + # Return true if window/display is narrow + # return self.page.window_height >= self.page.window_width + return self.page.height >= self.page.width + + def is_landscape(self) -> bool: + # Return true if window/display is wide + return self.page.width > self.page.height + + def create_appbar(self) -> AppBar: + appbar = AppBar( + # leading=menu_button, + # leading_width=40, + # bgcolor=colors.SURFACE_VARIANT, + toolbar_height=48, + # elevation=8, + ) + + appbar.actions = [ + Row( + [ + IconButton( + icon=icons.HELP, + ) + ] + ) + ] + return appbar + + +def create_page(title: str, body: str): + return Row( + controls=[ + Column( + horizontal_alignment="stretch", + controls=[ + Card(content=Container(Text(title, weight="bold"), padding=8)), + Text(body), + ], + expand=True, + ), + ], + expand=True, + ) + + +def main(page: Page, title="Basic Responsive Menu"): + + pages = [ + ( + NavigationRailDestination( + icon=icons.LANDSCAPE_OUTLINED, + selected_icon=icons.LANDSCAPE, + label="Menu in landscape", + ), + create_page( + "Menu in landscape", + "Menu in landscape is by default shown, side by side with the main content, but can be " + "hidden with the menu button.", + ), + ), + ( + NavigationRailDestination( + icon=icons.PORTRAIT_OUTLINED, + selected_icon=icons.PORTRAIT, + label="Menu in portrait", + ), + create_page( + "Menu in portrait", + "Menu in portrait is mainly expected to be used on a smaller mobile device." + "\n\n" + "The menu is by default hidden, and when shown with the menu button it is placed on top of the main " + "content." + "\n\n" + "In addition to the menu button, menu can be dismissed by a tap/click on the main content area.", + ), + ), + ( + NavigationRailDestination( + icon=icons.INSERT_EMOTICON_OUTLINED, + selected_icon=icons.INSERT_EMOTICON, + label="Minimize to icons", + ), + create_page( + "Minimize to icons", + "ResponsiveMenuLayout has a parameter minimize_to_icons. " + "Set it to True and the menu is shown as icons only, when normally it would be hidden.\n" + "\n\n" + "Try this with the 'Minimize to icons' toggle in the top bar." + "\n\n" + "There are also landscape_minimize_to_icons and portrait_minimize_to_icons properties that you can " + "use to set this property differently in each orientation.", + ), + ), + ] + + menu_layout = MenuLayout( + page=page, + pages=pages, + title="Basic Desktop App Layout", + window_size=(1280, 720), + ) + + page.add(menu_layout) + + +if __name__ == "__main__": + flet.app( + target=main, + ) diff --git a/app/components/navigation.py b/app/components/navigation.py new file mode 100644 index 00000000..1603bd3a --- /dev/null +++ b/app/components/navigation.py @@ -0,0 +1,76 @@ +import flet +from flet import ( + Column, + FloatingActionButton, + Icon, + NavigationRail, + NavigationRailDestination, + Page, + Row, + Text, + VerticalDivider, + icons, + colors, +) + + +def main(page: Page): + + rail = NavigationRail( + selected_index=0, + label_type="all", + extended=True, + min_width=100, + min_extended_width=250, + group_alignment=-0.9, + bgcolor=colors.BLUE_GREY_900, + destinations=[ + NavigationRailDestination( + icon=icons.SPEED, + label="Dashboard", + ), + NavigationRailDestination( + icon=icons.WORK, + label="Projects", + ), + NavigationRailDestination( + icon=icons.DATE_RANGE, + label="Time Tracking", + ), + NavigationRailDestination( + icon=icons.CONTACT_MAIL, + label="Contacts", + ), + NavigationRailDestination( + icon=icons.HANDSHAKE, + label="Clients", + ), + NavigationRailDestination( + icon=icons.HISTORY_EDU, + label="Contracts", + ), + NavigationRailDestination( + icon=icons.OUTGOING_MAIL, + label="Invoices", + ), + NavigationRailDestination( + icon=icons.SETTINGS, + label_content=Text("Settings"), + ), + ], + on_change=lambda e: print("Selected destination:", e.control.selected_index), + ) + + page.add( + Row( + [ + rail, + # VerticalDivider(width=1), + Column([Text("Body!")], alignment="start", expand=True), + ], + expand=True, + ) + ) + + +flet.app(target=main) diff --git a/app/components/routing.py b/app/components/routing.py new file mode 100644 index 00000000..65b340c9 --- /dev/null +++ b/app/components/routing.py @@ -0,0 +1,41 @@ +import flet +from flet import AppBar, ElevatedButton, Page, Text, View, colors + + +def main(page: Page): + page.title = "Routes Example" + + def route_change(route): + page.views.clear() + page.views.append( + View( + "/", + [ + AppBar(title=Text("Flet app"), bgcolor=colors.SURFACE_VARIANT), + ElevatedButton("Visit Store", on_click=lambda _: page.go("/store")), + ], + ) + ) + if page.route == "/store": + page.views.append( + View( + "/store", + [ + AppBar(title=Text("Store"), bgcolor=colors.SURFACE_VARIANT), + ElevatedButton("Go Home", on_click=lambda _: page.go("/")), + ], + ) + ) + page.update() + + def view_pop(view): + page.views.pop() + top_view = page.views[-1] + page.go(top_view.route) + + page.on_route_change = route_change + page.on_view_pop = view_pop + page.go(page.route) + + +flet.app(target=main) diff --git a/app/components/select_time_tracking.py b/app/components/select_time_tracking.py new file mode 100644 index 00000000..b28690ba --- /dev/null +++ b/app/components/select_time_tracking.py @@ -0,0 +1,67 @@ +import flet +from flet import ( + ElevatedButton, + FilePicker, + FilePickerResultEvent, + Page, + Row, + Text, + icons, + Radio, + RadioGroup, + Column, +) + + +def main(page: Page): + def on_time_tracking_preference_change(event): + pass + + # a RadioGroup group of radio buttons to select from the following options: Calendar File, Cloud Calendar, Spreadsheet + time_tracking_preference = RadioGroup( + Column( + [ + Radio(label="Calendar File", value="calendar_file"), + Radio(label="Cloud Calendar", value="cloud_calendar"), + Radio(label="Spreadsheet", value="spreadsheet"), + ], + ), + on_change=on_time_tracking_preference_change, + ) + + def on_file_picked(result: FilePickerResultEvent): + picked_file = result.files[0] + selected_file_display.value = picked_file.path + selected_file_display.update() + + pick_file_dialog = FilePicker(on_result=on_file_picked) + selected_file_display = Text() + + page.overlay.append(pick_file_dialog) + + page.add( + Column( + [ + time_tracking_preference, + Row( + [ + ElevatedButton( + "Select file", + icon=icons.UPLOAD_FILE, + on_click=lambda _: pick_file_dialog.pick_files( + allow_multiple=False + ), + ), + selected_file_display, + ] + ), + ElevatedButton( + "Import data", + icon=icons.IMPORT_EXPORT, + ), + ] + ) + ) + + +flet.app(target=main) diff --git a/app/components/view_contact.py b/app/components/view_contact.py new file mode 100644 index 00000000..0da1cff6 --- /dev/null +++ b/app/components/view_contact.py @@ -0,0 +1,124 @@ +import flet +from flet import ( + UserControl, + Page, + View, + Text, + Column, + Row, + Icon, + Card, + Container, + ListTile, + IconButton, +) +from flet import icons + +from tuttle.controller import Controller +from tuttle.model import ( + Contact, + Address, +) + +from tuttle_tests.demo import demo_contact, contact_two + + +class App(UserControl): + def __init__( + self, + con: Controller, + ): + super().__init__() + self.con = con + + +class AppView(UserControl): + def __init__( + self, + app: App, + ): + super().__init__() + self.app = app + + +class ContactView(AppView): + """View of the Contact model class.""" + + def __init__( + self, + contact: Contact, + app: App, + ): + super().__init__(app) + self.contact = contact + + def get_address(self): + if self.contact.address: + return self.contact.address.printed + else: + return "" + + def delete_contact(self, event): + """Delete the contact.""" + self.app.con.delete(self.contact) + + def build(self): + """Obligatory build method.""" + self.view = Card( + content=Container( + content=Column( + [ + ListTile( + leading=Icon(icons.CONTACT_MAIL), + title=Text(self.contact.name), + subtitle=Column( + [ + Text(self.contact.email), + Text(self.get_address()), + ] + ), + ), + Row( + [ + IconButton( + icon=icons.EDIT, + ), + IconButton( + icon=icons.DELETE, + on_click=self.delete_contact, + ), + ], + alignment="end", + ), + ] + ), + # width=400, + padding=10, + ) + ) + return self.view + + +def main(page: Page): + + con = Controller( + in_memory=True, + verbose=True, + ) + + con.store(demo_contact) + con.store(contact_two) + + app = App( + con, + ) + + for contact in con.contacts: + page.add(ContactView(contact, app)) + + page.update() + + +flet.app( + target=main, +) diff --git a/app/components/view_contract.py b/app/components/view_contract.py new file mode 100644 index 00000000..8bc2729d --- /dev/null +++ b/app/components/view_contract.py @@ -0,0 +1,55 @@ +import flet +from flet import ( + UserControl, + Page, + View, + Text, + Column, + Row, + KeyboardEvent, + SnackBar, + NavigationRail, + NavigationRailDestination, + VerticalDivider, + Icon, + Card, + Container, + ListTile, + TextButton, +) +from flet import icons + +from tuttle.model import Contract + + +class ContractView(UserControl): + """Main class of the application GUI.""" + + def __init__( + self, + contract: Contract, + ): + super().__init__() + self.contract = contract + + def build(self): + """Obligatory build method.""" + self.view = Card( + content=Container( + content=Column( + [ + ListTile( + leading=Icon(icons.HISTORY_EDU), + title=Text(self.contract.title), + subtitle=Text(self.contract.client.name), + ), + Row( + [TextButton("Buy tickets"), TextButton("Listen")], + alignment="end", + ), + ] + ), + width=400, + padding=10, + ) + ) diff --git a/app/components/view_user.py b/app/components/view_user.py new file mode 100644 index 00000000..e93eed99 --- /dev/null +++ b/app/components/view_user.py @@ -0,0 +1,108 @@ +import flet +from flet import ( + UserControl, + Page, + View, + Text, + Column, + Row, + KeyboardEvent, + SnackBar, + NavigationRail, + NavigationRailDestination, + VerticalDivider, + Icon, + CircleAvatar, +) +from flet import icons, colors + +from tuttle.model import ( + User, + Address, + BankAccount, +) + + +def demo_user(): + user = User( + name="Harry Tuttle", + subtitle="Heating Engineer", + website="https://tuttle-dev.github.io/tuttle/", + email="mail@tuttle.com", + phone_number="+55555555555", + VAT_number="27B-6", + address=Address( + name="Harry Tuttle", + street="Main Street", + number="450", + city="Sao Paolo", + postal_code="555555", + country="Brazil", + ), + bank_account=BankAccount( + name="Giro", + IBAN="BZ99830994950003161565", + ), + ) + return user + + +class UserView(UserControl): + """Main class of the application GUI.""" + + def __init__( + self, + user: User, + ): + super().__init__() + self.user = user + + def build(self): + """Obligatory build method.""" + + self.avatar = CircleAvatar( + content=Icon(icons.PERSON), + bgcolor=colors.WHITE, + ) + + self.view = Column( + [ + Row( + [ + self.avatar, + Text( + self.user.name, + weight="bold", + ), + ] + ), + Row( + [ + Text( + self.user.email, + italic=True, + ), + ] + ), + ] + ) + + return self.view + + +def main(page: Page): + + user_view = UserView( + user=demo_user(), + ) + + page.add( + user_view, + ) + + page.update() + + +flet.app( + target=main, +) diff --git a/app/examples/alert_dialog.py b/app/examples/alert_dialog.py new file mode 100644 index 00000000..e89f625b --- /dev/null +++ b/app/examples/alert_dialog.py @@ -0,0 +1,44 @@ +import flet +from flet import AlertDialog, ElevatedButton, Page, Text, TextButton + + +def main(page: Page): + page.title = "AlertDialog examples" + + dlg = AlertDialog( + title=Text("Hello, you!"), on_dismiss=lambda e: print("Dialog dismissed!") + ) + + def close_dlg(e): + dlg_modal.open = False + page.update() + + dlg_modal = AlertDialog( + modal=True, + title=Text("Please confirm"), + content=Text("Do you really want to delete all those files?"), + actions=[ + TextButton("Yes", on_click=close_dlg), + TextButton("No", on_click=close_dlg), + ], + actions_alignment="end", + on_dismiss=lambda e: print("Modal dialog dismissed!"), + ) + + def open_dlg(e): + page.dialog = dlg + dlg.open = True + page.update() + + def open_dlg_modal(e): + page.dialog = dlg_modal + dlg_modal.open = True + page.update() + + page.add( + ElevatedButton("Open dialog", on_click=open_dlg), + ElevatedButton("Open modal dialog", on_click=open_dlg_modal), + ) + + +flet.app(target=main) diff --git a/app/examples/banner.py b/app/examples/banner.py new file mode 100644 index 00000000..aba2c8d8 --- /dev/null +++ b/app/examples/banner.py @@ -0,0 +1,30 @@ +import flet +from flet import Banner, ElevatedButton, Icon, Text, TextButton, colors, icons + + +def main(page): + def close_banner(e): + page.banner.open = False + page.update() + + page.banner = Banner( + bgcolor=colors.AMBER_100, + leading=Icon(icons.WARNING_AMBER_ROUNDED, color=colors.AMBER, size=40), + content=Text( + "Oops, there were some errors while trying to delete the file. What would you like me to do?" + ), + actions=[ + TextButton("Retry", on_click=close_banner), + TextButton("Ignore", on_click=close_banner), + TextButton("Cancel", on_click=close_banner), + ], + ) + + def show_banner_click(e): + page.banner.open = True + page.update() + + page.add(ElevatedButton("Show Banner", on_click=show_banner_click)) + + +flet.app(target=main) diff --git a/app/examples/card.py b/app/examples/card.py new file mode 100644 index 00000000..63a4a35e --- /dev/null +++ b/app/examples/card.py @@ -0,0 +1,55 @@ +import flet +from flet import Card, Column, Container, Icon, ListTile, Row, Text, TextButton, icons + + +def main(page): + page.title = "Card Example" + page.add( + Card( + content=Container( + content=Column( + [ + ListTile( + leading=Icon(icons.HISTORY_EDU), + title=Text("The Enchanted Nightingale"), + subtitle=Text( + "Music by Julie Gable. Lyrics by Sidney Stein." + ), + ), + Row( + [TextButton("Buy tickets"), TextButton("Listen")], + alignment="end", + ), + ] + ), + width=400, + padding=10, + ) + ) + ) + page.add( + Card( + content=Container( + content=Column( + [ + ListTile( + leading=Icon(icons.ALBUM), + title=Text("The Enchanted Nightingale"), + subtitle=Text( + "Music by Julie Gable. Lyrics by Sidney Stein." + ), + ), + Row( + [TextButton("Buy tickets"), TextButton("Listen")], + alignment="end", + ), + ] + ), + width=400, + padding=10, + ) + ) + ) + + +flet.app(target=main) diff --git a/app/examples/circle_avatar.py b/app/examples/circle_avatar.py new file mode 100644 index 00000000..231fb99b --- /dev/null +++ b/app/examples/circle_avatar.py @@ -0,0 +1,44 @@ +import flet +from flet import CircleAvatar, Icon, Stack, Text, alignment, colors, icons +from flet.container import Container + + +def main(page): + # a "normal" avatar with background image + a1 = CircleAvatar( + foreground_image_url="https://avatars.githubusercontent.com/u/5041459?s=88&v=4", + content=Text("FF"), + ) + # avatar with failing foregroung image and fallback text + a2 = CircleAvatar( + foreground_image_url="https://avatars.githubusercontent.com/u/_5041459?s=88&v=4", + content=Text("FF"), + ) + # avatar with icon, aka icon with inverse background + a3 = CircleAvatar( + content=Icon(icons.ABC), + ) + # avatar with icon and custom colors + a4 = CircleAvatar( + content=Icon(icons.WARNING_ROUNDED), + color=colors.YELLOW_200, + bgcolor=colors.AMBER_700, + ) + # avatar with online status + a5 = Stack( + [ + CircleAvatar( + foreground_image_url="https://avatars.githubusercontent.com/u/5041459?s=88&v=4" + ), + Container( + content=CircleAvatar(bgcolor=colors.GREEN, radius=5), + alignment=alignment.bottom_left, + ), + ], + width=40, + height=40, + ) + page.add(a1, a2, a3, a4, a5) + + +flet.app(target=main) diff --git a/app/examples/dropdown.py b/app/examples/dropdown.py new file mode 100644 index 00000000..24c93f3d --- /dev/null +++ b/app/examples/dropdown.py @@ -0,0 +1,20 @@ +import flet +from flet import Dropdown, Page, dropdown + + +def main(page: Page): + page.add( + Dropdown( + label="Color", + hint_text="Choose your favourite color?", + options=[ + dropdown.Option("Red"), + dropdown.Option("Green"), + dropdown.Option("Blue"), + ], + autofocus=True, + ) + ) + + +flet.app(target=main) diff --git a/app/examples/file_upload.py b/app/examples/file_upload.py new file mode 100644 index 00000000..87eb3b65 --- /dev/null +++ b/app/examples/file_upload.py @@ -0,0 +1,41 @@ +import flet +from flet import ( + ElevatedButton, + FilePicker, + FilePickerResultEvent, + Page, + Row, + Text, + icons, +) + + +def main(page: Page): + def pick_files_result(e: FilePickerResultEvent): + selected_files.value = ( + ", ".join(map(lambda f: f.name, e.files)) if e.files else "Cancelled!" + ) + selected_files.update() + + pick_files_dialog = FilePicker(on_result=pick_files_result) + selected_files = Text() + + page.overlay.append(pick_files_dialog) + + page.add( + Row( + [ + ElevatedButton( + "Pick files", + icon=icons.UPLOAD_FILE, + on_click=lambda _: pick_files_dialog.pick_files( + allow_multiple=True + ), + ), + selected_files, + ] + ) + ) + + +flet.app(target=main) diff --git a/app/examples/footer.py b/app/examples/footer.py new file mode 100644 index 00000000..e6b4e876 --- /dev/null +++ b/app/examples/footer.py @@ -0,0 +1,21 @@ +import flet +from flet import Column, Container, Page, Row, Text + + +def main(page: Page): + + main_content = Column() + + # for i in range(100): + # main_content.controls.append(Text(f"Line {i}")) + + page.padding = 0 + page.spacing = 0 + page.horizontal_alignment = "stretch" + page.add( + Container(main_content, padding=10, expand=True), + Row([Container(Text("Footer"), bgcolor="yellow", padding=5, expand=True)]), + ) + + +flet.app(target=main) diff --git a/app/examples/icon_browser.py b/app/examples/icon_browser.py new file mode 100644 index 00000000..f1c0b6ad --- /dev/null +++ b/app/examples/icon_browser.py @@ -0,0 +1,147 @@ +import logging +import os +from itertools import islice + +import flet +from flet import ( + Column, + Container, + GridView, + Icon, + IconButton, + Page, + Row, + SnackBar, + Text, + TextButton, + TextField, + UserControl, + alignment, + colors, + icons, +) + +# logging.basicConfig(level=logging.INFO) + +os.environ["FLET_WS_MAX_MESSAGE_SIZE"] = "8000000" + + +class IconBrowser(UserControl): + def __init__(self, expand=False, height=500): + super().__init__() + if expand: + self.expand = expand + else: + self.height = height + + def build(self): + def batches(iterable, batch_size): + iterator = iter(iterable) + while batch := list(islice(iterator, batch_size)): + yield batch + + # fetch all icon constants from icons.py module + icons_list = [] + list_started = False + for key, value in vars(icons).items(): + if key == "TEN_K": + list_started = True + if list_started: + icons_list.append(value) + + search_txt = TextField( + expand=1, + hint_text="Enter keyword and press search button", + autofocus=True, + on_submit=lambda e: display_icons(e.control.value), + ) + + def search_click(e): + display_icons(search_txt.value) + + search_query = Row( + [search_txt, IconButton(icon=icons.SEARCH, on_click=search_click)] + ) + + search_results = GridView( + expand=1, + runs_count=10, + max_extent=150, + spacing=5, + run_spacing=5, + child_aspect_ratio=1, + ) + status_bar = Text() + + def copy_to_clipboard(e): + icon_key = e.control.data + print("Copy to clipboard:", icon_key) + self.page.set_clipboard(e.control.data) + self.page.show_snack_bar(SnackBar(Text(f"Copied {icon_key}"), open=True)) + + def search_icons(search_term: str): + for icon_name in icons_list: + if search_term != "" and search_term in icon_name: + yield icon_name + + def display_icons(search_term: str): + + # clean search results + search_query.disabled = True + self.update() + + search_results.clean() + + for batch in batches(search_icons(search_term.lower()), 200): + for icon_name in batch: + icon_key = f"icons.{icon_name.upper()}" + search_results.controls.append( + TextButton( + content=Container( + content=Column( + [ + Icon(name=icon_name, size=30), + Text( + value=f"{icon_name}", + size=12, + width=100, + no_wrap=True, + text_align="center", + color=colors.ON_SURFACE_VARIANT, + ), + ], + spacing=5, + alignment="center", + horizontal_alignment="center", + ), + alignment=alignment.center, + ), + tooltip=f"{icon_key}\nClick to copy to a clipboard", + on_click=copy_to_clipboard, + data=icon_key, + ) + ) + status_bar.value = f"Icons found: {len(search_results.controls)}" + self.update() + + if len(search_results.controls) == 0: + self.page.show_snack_bar(SnackBar(Text("No icons found"), open=True)) + search_query.disabled = False + self.update() + + return Column( + [ + search_query, + search_results, + status_bar, + ], + expand=True, + ) + + +def main(page: Page): + page.title = "Flet icons browser" + page.add(IconBrowser(expand=True)) + + +flet.app(target=main) diff --git a/app/examples/list_tile.py b/app/examples/list_tile.py new file mode 100644 index 00000000..27430ed2 --- /dev/null +++ b/app/examples/list_tile.py @@ -0,0 +1,80 @@ +import flet +from flet import ( + Card, + Column, + Container, + Icon, + Image, + ListTile, + PopupMenuButton, + PopupMenuItem, + Text, + icons, + padding, +) + + +def main(page): + page.title = "ListTile Examples" + page.add( + Card( + content=Container( + width=500, + content=Column( + [ + ListTile( + title=Text("One-line list tile"), + ), + ListTile(title=Text("One-line dense list tile"), dense=True), + ListTile( + leading=Icon(icons.SETTINGS), + title=Text("One-line selected list tile"), + selected=True, + ), + ListTile( + leading=Image(src="/icons/icon-192.png", fit="contain"), + title=Text("One-line with leading control"), + ), + ListTile( + title=Text("One-line with trailing control"), + trailing=PopupMenuButton( + icon=icons.MORE_VERT, + items=[ + PopupMenuItem(text="Item 1"), + PopupMenuItem(text="Item 2"), + ], + ), + ), + ListTile( + leading=Icon(icons.ALBUM), + title=Text("One-line with leading and trailing controls"), + trailing=PopupMenuButton( + icon=icons.MORE_VERT, + items=[ + PopupMenuItem(text="Item 1"), + PopupMenuItem(text="Item 2"), + ], + ), + ), + ListTile( + leading=Icon(icons.SNOOZE), + title=Text("Two-line with leading and trailing controls"), + subtitle=Text("Here is a second title."), + trailing=PopupMenuButton( + icon=icons.MORE_VERT, + items=[ + PopupMenuItem(text="Item 1"), + PopupMenuItem(text="Item 2"), + ], + ), + ), + ], + spacing=0, + ), + padding=padding.symmetric(vertical=10), + ) + ) + ) + + +flet.app(target=main) diff --git a/app/examples/list_view.py b/app/examples/list_view.py new file mode 100644 index 00000000..9ef9b7bf --- /dev/null +++ b/app/examples/list_view.py @@ -0,0 +1,26 @@ +from time import sleep +import flet +from flet import ListView, Page, Text + + +def main(page: Page): + page.title = "Auto-scrolling ListView" + + lv = ListView(expand=1, spacing=10, padding=20, auto_scroll=False) + + count = 1 + + for i in range(0, 60): + lv.controls.append(Text(f"Line {count}")) + count += 1 + + page.add(lv) + + for i in range(0, 60): + sleep(1) + lv.controls.append(Text(f"Line {count}")) + count += 1 + page.update() + + +flet.app(target=main) diff --git a/app/examples/navigation_rail.py b/app/examples/navigation_rail.py new file mode 100644 index 00000000..ac99202a --- /dev/null +++ b/app/examples/navigation_rail.py @@ -0,0 +1,56 @@ +import flet +from flet import ( + Column, + FloatingActionButton, + Icon, + NavigationRail, + NavigationRailDestination, + Page, + Row, + Text, + VerticalDivider, + icons, +) + + +def main(page: Page): + + rail = NavigationRail( + selected_index=0, + label_type="all", + extended=True, + min_width=100, + min_extended_width=400, + leading=FloatingActionButton(icon=icons.CREATE, text="Add"), + group_alignment=-0.9, + destinations=[ + NavigationRailDestination( + icon=icons.FAVORITE_BORDER, selected_icon=icons.FAVORITE, label="First" + ), + NavigationRailDestination( + icon_content=Icon(icons.BOOKMARK_BORDER), + selected_icon_content=Icon(icons.BOOKMARK), + label="Second", + ), + NavigationRailDestination( + icon=icons.SETTINGS_OUTLINED, + selected_icon_content=Icon(icons.SETTINGS), + label_content=Text("Settings"), + ), + ], + on_change=lambda e: print("Selected destination:", e.control.selected_index), + ) + + page.add( + Row( + [ + rail, + VerticalDivider(width=1), + Column([Text("Body!")], alignment="start", expand=True), + ], + expand=True, + ) + ) + + +flet.app(target=main) diff --git a/app/examples/responsive_menu_layout.py b/app/examples/responsive_menu_layout.py new file mode 100644 index 00000000..b2a364d6 --- /dev/null +++ b/app/examples/responsive_menu_layout.py @@ -0,0 +1,403 @@ +from copy import deepcopy + +import flet +from flet import AppBar +from flet import Card +from flet import Column +from flet import Container +from flet import ElevatedButton +from flet import IconButton +from flet import NavigationRail +from flet import NavigationRailDestination +from flet import Page +from flet import Row +from flet import Stack +from flet import Switch +from flet import Text +from flet import VerticalDivider +from flet import colors +from flet import icons +from flet import slugify + + +class ResponsiveMenuLayout(Row): + def __init__( + self, + page, + pages, + *args, + support_routes=True, + menu_extended=True, + minimize_to_icons=False, + landscape_minimize_to_icons=False, + portrait_minimize_to_icons=False, + **kwargs, + ): + super().__init__(*args, **kwargs) + + self.page = page + self.pages = pages + + self._minimize_to_icons = minimize_to_icons + self._landscape_minimize_to_icons = landscape_minimize_to_icons + self._portrait_minimize_to_icons = portrait_minimize_to_icons + self._support_routes = support_routes + + self.expand = True + + self.navigation_items = [navigation_item for navigation_item, _ in pages] + self.routes = [ + f"/{item.pop('route', None) or slugify(item['label'])}" + for item in self.navigation_items + ] + self.navigation_rail = self.build_navigation_rail() + self.update_destinations() + self._menu_extended = menu_extended + self.navigation_rail.extended = menu_extended + + page_contents = [page_content for _, page_content in pages] + + self.menu_panel = Row( + controls=[self.navigation_rail, VerticalDivider(width=1)], + spacing=0, + tight=True, + ) + self.content_area = Column(page_contents, expand=True) + + self._was_portrait = self.is_portrait() + self._panel_visible = self.is_landscape() + + self.set_navigation_content() + + if support_routes: + self._route_change(page.route) + self.page.on_route_change = self._on_route_change + self._change_displayed_page() + + self.page.on_resize = self.handle_resize + + def select_page(self, page_number): + self.navigation_rail.selected_index = page_number + self._change_displayed_page() + + @property + def minimize_to_icons(self) -> bool: + return self._minimize_to_icons or ( + self._landscape_minimize_to_icons and self._portrait_minimize_to_icons + ) + + @minimize_to_icons.setter + def minimize_to_icons(self, value: bool): + self._minimize_to_icons = value + self.set_navigation_content() + + @property + def landscape_minimize_to_icons(self) -> bool: + return self._landscape_minimize_to_icons or self._minimize_to_icons + + @landscape_minimize_to_icons.setter + def landscape_minimize_to_icons(self, value: bool): + self._landscape_minimize_to_icons = value + self.set_navigation_content() + + @property + def portrait_minimize_to_icons(self) -> bool: + return self._portrait_minimize_to_icons or self._minimize_to_icons + + @portrait_minimize_to_icons.setter + def portrait_minimize_to_icons(self, value: bool): + self._portrait_minimize_to_icons = value + self.set_navigation_content() + + @property + def menu_extended(self) -> bool: + return self._menu_extended + + @menu_extended.setter + def menu_extended(self, value: bool): + self._menu_extended = value + + dimension_minimized = ( + self.landscape_minimize_to_icons + if self.is_landscape() + else self.portrait_minimize_to_icons + ) + if not dimension_minimized or self._panel_visible: + self.navigation_rail.extended = value + + def _navigation_change(self, e): + self._change_displayed_page() + self.check_toggle_on_select() + self.page.update() + + def _change_displayed_page(self): + page_number = self.navigation_rail.selected_index + if self._support_routes: + self.page.route = self.routes[page_number] + for i, content_page in enumerate(self.content_area.controls): + content_page.visible = page_number == i + + def _route_change(self, route): + try: + page_number = self.routes.index(route) + except ValueError: + page_number = 0 + + self.select_page(page_number) + + def _on_route_change(self, event): + self._route_change(event.route) + self.page.update() + + def build_navigation_rail(self): + return NavigationRail( + selected_index=0, + label_type="none", + on_change=self._navigation_change, + ) + + def update_destinations(self, icons_only=False): + navigation_items = self.navigation_items + if icons_only: + navigation_items = deepcopy(navigation_items) + for item in navigation_items: + item.pop("label") + + self.navigation_rail.destinations = [ + NavigationRailDestination(**nav_specs) for nav_specs in navigation_items + ] + self.navigation_rail.label_type = "none" if icons_only else "all" + + def handle_resize(self, e): + if self._was_portrait != self.is_portrait(): + self._was_portrait = self.is_portrait() + self._panel_visible = self.is_landscape() + self.set_navigation_content() + self.page.update() + + def toggle_navigation(self, event=None): + self._panel_visible = not self._panel_visible + self.set_navigation_content() + self.page.update() + + def check_toggle_on_select(self): + if self.is_portrait() and self._panel_visible: + self.toggle_navigation() + + def set_navigation_content(self): + if self.is_landscape(): + self.add_landscape_content() + else: + self.add_portrait_content() + + def add_landscape_content(self): + self.controls = [self.menu_panel, self.content_area] + if self.landscape_minimize_to_icons: + self.update_destinations(icons_only=not self._panel_visible) + self.menu_panel.visible = True + if not self._panel_visible: + self.navigation_rail.extended = False + else: + self.navigation_rail.extended = self.menu_extended + else: + self.update_destinations() + self.navigation_rail.extended = self._menu_extended + self.menu_panel.visible = self._panel_visible + + def add_portrait_content(self): + if self.portrait_minimize_to_icons and not self._panel_visible: + self.controls = [self.menu_panel, self.content_area] + self.update_destinations(icons_only=True) + self.menu_panel.visible = True + self.navigation_rail.extended = False + else: + if self._panel_visible: + dismiss_shield = Container( + expand=True, + on_click=self.toggle_navigation, + ) + self.controls = [ + Stack( + controls=[self.content_area, dismiss_shield, self.menu_panel], + expand=True, + ) + ] + else: + self.controls = [ + Stack(controls=[self.content_area, self.menu_panel], expand=True) + ] + self.update_destinations() + self.navigation_rail.extended = self.menu_extended + self.menu_panel.visible = self._panel_visible + + def is_portrait(self) -> bool: + # Return true if window/display is narrow + # return self.page.window_height >= self.page.window_width + return self.page.height >= self.page.width + + def is_landscape(self) -> bool: + # Return true if window/display is wide + return self.page.width > self.page.height + + +if __name__ == "__main__": + + def main(page: Page, title="Basic Responsive Menu"): + + page.title = title + + menu_button = IconButton(icons.MENU) + + page.appbar = AppBar( + leading=menu_button, + leading_width=40, + bgcolor=colors.SURFACE_VARIANT, + ) + + pages = [ + ( + dict( + icon=icons.LANDSCAPE_OUTLINED, + selected_icon=icons.LANDSCAPE, + label="Menu in landscape", + ), + create_page( + "Menu in landscape", + "Menu in landscape is by default shown, side by side with the main content, but can be " + "hidden with the menu button.", + ), + ), + ( + dict( + icon=icons.PORTRAIT_OUTLINED, + selected_icon=icons.PORTRAIT, + label="Menu in portrait", + ), + create_page( + "Menu in portrait", + "Menu in portrait is mainly expected to be used on a smaller mobile device." + "\n\n" + "The menu is by default hidden, and when shown with the menu button it is placed on top of the main " + "content." + "\n\n" + "In addition to the menu button, menu can be dismissed by a tap/click on the main content area.", + ), + ), + ( + dict( + icon=icons.INSERT_EMOTICON_OUTLINED, + selected_icon=icons.INSERT_EMOTICON, + label="Minimize to icons", + ), + create_page( + "Minimize to icons", + "ResponsiveMenuLayout has a parameter minimize_to_icons. " + "Set it to True and the menu is shown as icons only, when normally it would be hidden.\n" + "\n\n" + "Try this with the 'Minimize to icons' toggle in the top bar." + "\n\n" + "There are also landscape_minimize_to_icons and portrait_minimize_to_icons properties that you can " + "use to set this property differently in each orientation.", + ), + ), + ( + dict( + icon=icons.COMPARE_ARROWS_OUTLINED, + selected_icon=icons.COMPARE_ARROWS, + label="Menu width", + ), + create_page( + "Menu width", + "ResponsiveMenuLayout has a parameter manu_extended. " + "Set it to False to place menu labels under the icons instead of beside them." + "\n\n" + "Try this with the 'Menu width' toggle in the top bar.", + ), + ), + ( + dict( + icon=icons.ROUTE_OUTLINED, + selected_icon=icons.ROUTE, + label="Route support", + route="custom-route", + ), + create_page( + "Route support", + "ResponsiveMenuLayout has a parameter support_routes, which is True by default. " + "\n\n" + "Routes are useful only in the web, where the currently selected page is shown in the url, " + "and you can open the app directly on a specific page with the right url." + "\n\n" + "You can specify a route explicitly with a 'route' item in the menu dict (see this page in code). " + "If you do not specify the route, a slugified version of the page label is used " + "('Menu width' becomes 'menu-width').", + ), + ), + ( + dict( + icon=icons.PLUS_ONE_OUTLINED, + selected_icon=icons.PLUS_ONE, + label="Fine control", + ), + create_page( + "Adjust navigation rail", + "NavigationRail is accessible via the navigation_rail attribute of the ResponsiveMenuLayout. " + "In this demo it is used to add the leading button control." + "\n\n" + "These NavigationRail attributes are used by the ResponsiveMenuLayout, and changing them directly " + "will probably break it:\n" + "- destinations\n" + "- extended\n" + "- label_type\n" + "- on_change\n", + ), + ), + ] + + menu_layout = ResponsiveMenuLayout(page, pages) + + page.appbar.actions = [ + Row( + [ + Text("Minimize\nto icons"), + Switch(on_change=lambda e: toggle_icons_only(menu_layout)), + Text("Menu\nwidth"), + Switch( + value=True, on_change=lambda e: toggle_menu_width(menu_layout) + ), + ] + ) + ] + + menu_layout.navigation_rail.leading = ElevatedButton( + "Add", icon=icons.ADD, expand=True, on_click=lambda e: print("Add clicked") + ) + + page.add(menu_layout) + + menu_button.on_click = lambda e: menu_layout.toggle_navigation() + + def create_page(title: str, body: str): + return Row( + controls=[ + Column( + horizontal_alignment="stretch", + controls=[ + Card(content=Container(Text(title, weight="bold"), padding=8)), + Text(body), + ], + expand=True, + ), + ], + expand=True, + ) + + def toggle_icons_only(menu: ResponsiveMenuLayout): + menu.minimize_to_icons = not menu.minimize_to_icons + menu.page.update() + + def toggle_menu_width(menu: ResponsiveMenuLayout): + menu.menu_extended = not menu.menu_extended + menu.page.update() + + flet.app(target=main) diff --git a/app/layout.py b/app/layout.py new file mode 100644 index 00000000..0654d21e --- /dev/null +++ b/app/layout.py @@ -0,0 +1,219 @@ +from copy import deepcopy + +import flet +from flet import ( + AppBar, + Column, + Row, + Container, + IconButton, + Icon, + NavigationRail, + NavigationRailDestination, + Page, + Text, + Card, + Divider, +) +from flet import colors, icons + + +class DesktopAppLayout(Row): + """A desktop app layout with a menu on the left.""" + + def __init__( + self, + title, + page, + pages, + *args, + window_size=(800, 600), + **kwargs, + ): + super().__init__(*args, **kwargs) + + self.page = page + self.pages = pages + + self.expand = True + + self.navigation_items = [navigation_item for navigation_item, _ in pages] + self.navigation_rail = self.build_navigation_rail() + self.update_destinations() + self._menu_extended = True + self.navigation_rail.extended = True + + self.menu_panel = Row( + controls=[ + self.navigation_rail, + ], + spacing=0, + tight=True, + ) + + page_contents = [page_content for _, page_content in pages] + self.content_area = Column(page_contents, expand=True) + + self._was_portrait = self.is_portrait() + self._panel_visible = self.is_landscape() + + self.set_content() + + self._change_displayed_page() + + self.page.on_resize = self.handle_resize + + self.page.appbar = self.create_appbar() + + self.window_size = window_size + self.page.window_width, self.page.window_height = self.window_size + + self.page.title = title + + def select_page(self, page_number): + self.navigation_rail.selected_index = page_number + self._change_displayed_page() + + def _navigation_change(self, e): + self._change_displayed_page() + self.page.update() + + def _change_displayed_page(self): + page_number = self.navigation_rail.selected_index + for i, content_page in enumerate(self.content_area.controls): + # update selected page + if i == page_number: + content_page.update_content() + content_page.visible = page_number == i + + def build_navigation_rail(self): + return NavigationRail( + selected_index=0, + label_type="none", + on_change=self._navigation_change, + # bgcolor=colors.SURFACE_VARIANT, + ) + + def update_destinations(self): + self.navigation_rail.destinations = self.navigation_items + self.navigation_rail.label_type = "all" + + def handle_resize(self, e): + pass + + def set_content(self): + self.controls = [self.menu_panel, self.content_area] + self.update_destinations() + self.navigation_rail.extended = self._menu_extended + self.menu_panel.visible = self._panel_visible + + def is_portrait(self) -> bool: + # Return true if window/display is narrow + # return self.page.window_height >= self.page.window_width + return self.page.height >= self.page.width + + def is_landscape(self) -> bool: + # Return true if window/display is wide + return self.page.width > self.page.height + + def create_appbar(self) -> AppBar: + appbar = AppBar( + # leading=menu_button, + # leading_width=40, + # bgcolor=colors.SURFACE_VARIANT, + toolbar_height=48, + # elevation=8, + ) + + appbar.actions = [ + Row( + [ + IconButton( + icon=icons.HELP, + ) + ] + ) + ] + return appbar + + +def create_page(title: str, body: str): + return Row( + controls=[ + Column( + horizontal_alignment="stretch", + controls=[ + Card(content=Container(Text(title, weight="bold"), padding=8)), + Text(body), + ], + expand=True, + ), + ], + expand=True, + ) + + +def main(page: Page): + + pages = [ + ( + NavigationRailDestination( + icon=icons.LANDSCAPE_OUTLINED, + selected_icon=icons.LANDSCAPE, + label="Menu in landscape", + ), + create_page( + "Menu in landscape", + "Menu in landscape is by default shown, side by side with the main content, but can be " + "hidden with the menu button.", + ), + ), + ( + NavigationRailDestination( + icon=icons.PORTRAIT_OUTLINED, + selected_icon=icons.PORTRAIT, + label="Menu in portrait", + ), + create_page( + "Menu in portrait", + "Menu in portrait is mainly expected to be used on a smaller mobile device." + "\n\n" + "The menu is by default hidden, and when shown with the menu button it is placed on top of the main " + "content." + "\n\n" + "In addition to the menu button, menu can be dismissed by a tap/click on the main content area.", + ), + ), + ( + NavigationRailDestination( + icon=icons.INSERT_EMOTICON_OUTLINED, + selected_icon=icons.INSERT_EMOTICON, + label="Minimize to icons", + ), + create_page( + "Minimize to icons", + "ResponsiveMenuLayout has a parameter minimize_to_icons. " + "Set it to True and the menu is shown as icons only, when normally it would be hidden.\n" + "\n\n" + "Try this with the 'Minimize to icons' toggle in the top bar." + "\n\n" + "There are also landscape_minimize_to_icons and portrait_minimize_to_icons properties that you can " + "use to set this property differently in each orientation.", + ), + ), + ] + + menu_layout = DesktopAppLayout( + page=page, + pages=pages, + title="Basic Desktop App Layout", + window_size=(1280, 720), + ) + + page.add(menu_layout) + + +if __name__ == "__main__": + flet.app( + target=main, + ) diff --git a/app/views.py b/app/views.py new file mode 100644 index 00000000..72c9a362 --- /dev/null +++ b/app/views.py @@ -0,0 +1,224 @@ +from flet import ( + UserControl, + Card, + Container, + Column, + Row, + ListTile, + Icon, + IconButton, + Text, + PopupMenuButton, + PopupMenuItem, +) +from flet import icons + +from tuttle.model import ( + Contact, + Contract, + Project, +) + + +class AppView(UserControl): + def __init__( + self, + app, + ): + super().__init__() + self.app = app + + +class ContactView(AppView): + """View of the Contact model class.""" + + def __init__( + self, + contact: Contact, + app, + ): + super().__init__(app) + self.contact = contact + + def get_address(self): + if self.contact.address: + return self.contact.address.printed + else: + return "" + + def delete_contact(self, event): + """Delete the contact.""" + self.app.con.delete(self.contact) + + def build(self): + """Obligatory build method.""" + self.view = Card( + content=Container( + content=Column( + [ + ListTile( + leading=Icon(icons.CONTACT_MAIL), + title=Text(self.contact.name), + subtitle=Column( + [ + Text(self.contact.email), + Text(self.get_address()), + ] + ), + ), + Row( + [ + IconButton( + icon=icons.EDIT, + ), + IconButton( + icon=icons.DELETE, + on_click=self.delete_contact, + ), + ], + alignment="end", + ), + ] + ), + # width=400, + padding=10, + ) + ) + return self.view + + +class ContactView2(Card): + def __init__( + self, + contact: Contact, + app, + ): + super().__init__() + self.contact = contact + self.app = app + + self.content = Container( + content=Column( + [ + ListTile( + leading=Icon(icons.CONTACT_MAIL), + title=Text(self.contact.name), + subtitle=Column( + [ + Text(self.contact.email), + Text(self.get_address()), + ] + ), + ), + Row( + [ + IconButton( + icon=icons.EDIT, + ), + IconButton( + icon=icons.DELETE, + # on_click=self.delete_contact, + ), + ], + alignment="end", + ), + ] + ), + # width=400, + padding=10, + ) + + # self.app.page.add(self) + + def get_address(self): + if self.contact.address: + return self.contact.address.printed + else: + return "" + + +def make_contact_view(contact: Contact): + return Card( + content=Container( + content=Column( + [ + ListTile( + leading=Icon(icons.CONTACT_MAIL), + title=Text(contact.name), + subtitle=Column( + [ + Text(contact.email), + Text(contact.print_address()), + ] + ), + ), + # Row( + # [ + # IconButton( + # icon=icons.EDIT, + # ), + # IconButton( + # icon=icons.DELETE, + # ), + # ], + # alignment="end", + # ), + ] + ), + # width=400, + padding=10, + ) + ) + + +def make_contract_view(contract: Contract): + return Card( + content=Container( + content=Row( + [ + Icon(icons.HISTORY_EDU), + Text(contract.title), + ], + ), + padding=12, + ) + ) + + +def make_project_view(project: Project): + return Card( + content=Container( + content=Column( + [ + ListTile( + leading=Icon(icons.WORK), + title=Text(project.title), + subtitle=Text(project.tag), + trailing=PopupMenuButton( + icon=icons.MORE_VERT, + items=[ + PopupMenuItem( + icon=icons.EDIT, + text="Edit", + ), + PopupMenuItem( + icon=icons.DELETE, + text="Delete", + ), + ], + ), + ), + Column( + [ + Text(f"Client: {project.client.name}"), + Text(f"Contract: {project.contract.title}"), + Text(f"Start: {project.start_date}"), + Text(f"End: {project.end_date}"), + ] + ), + ] + ), + # width=400, + padding=10, + ) + ) diff --git a/requirements.txt b/requirements.txt index dc6f4fe6..c9857286 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,9 +5,9 @@ pydantic sqlalchemy == 1.4.35 sqlmodel pyicloud -fints ics babel loguru pdfkit +flet pandera diff --git a/requirements_dev.txt b/requirements_dev.txt index 532c6c11..ee66b7da 100644 --- a/requirements_dev.txt +++ b/requirements_dev.txt @@ -4,3 +4,4 @@ bump2version black nbdime pre-commit +pyinstaller diff --git a/tuttle/banking.py b/tuttle/banking.py index b774f9ab..c34caad5 100644 --- a/tuttle/banking.py +++ b/tuttle/banking.py @@ -1,31 +1,32 @@ """Online banking functionality.""" import getpass -from fints.client import FinTS3PinTanClient + +# from fints.client import FinTS3PinTanClient from loguru import logger from .model import BankAccount, Bank -class Banking: - """.""" - - def __init__(self, bank: Bank): - self.bank = bank - self.product_id = None # TODO: register product ID before deployment - - def connect(self): - """Connect to the online banking interface via FinTS.""" - self.client = FinTS3PinTanClient( - self.bank.BLZ, # Your bank's BLZ - getpass.getpass("user name: "), # Your login name - getpass.getpass("PIN:"), # Your banking PIN - "https://hbci-pintan.gad.de/cgi-bin/hbciservlet", - product_id=self.product_id, - ) - - def get_accounts(self): - """List SEPA accounts.""" - with self.client as client: - accounts = client.get_sepa_accounts() - return accounts +# class Banking: +# """.""" + +# def __init__(self, bank: Bank): +# self.bank = bank +# self.product_id = None # TODO: register product ID before deployment + +# def connect(self): +# """Connect to the online banking interface via FinTS.""" +# self.client = FinTS3PinTanClient( +# self.bank.BLZ, # Your bank's BLZ +# getpass.getpass("user name: "), # Your login name +# getpass.getpass("PIN:"), # Your banking PIN +# "https://hbci-pintan.gad.de/cgi-bin/hbciservlet", +# product_id=self.product_id, +# ) + +# def get_accounts(self): +# """List SEPA accounts.""" +# with self.client as client: +# accounts = client.get_sepa_accounts() +# return accounts diff --git a/tuttle/controller.py b/tuttle/controller.py index 040c25bf..f3b06588 100644 --- a/tuttle/controller.py +++ b/tuttle/controller.py @@ -3,9 +3,11 @@ import os import sys import datetime +from typing import Type import pandas import sqlmodel +from sqlmodel import pool, SQLModel from loguru import logger @@ -13,7 +15,7 @@ class Controller: - """The main application class""" + """The application controller.""" def __init__(self, home_dir=None, verbose=False, in_memory=False): if home_dir is None: @@ -26,6 +28,8 @@ def __init__(self, home_dir=None, verbose=False, in_memory=False): self.db_engine = sqlmodel.create_engine( f"sqlite:///", echo=verbose, + connect_args={"check_same_thread": False}, + poolclass=pool.StaticPool, ) else: self.db_path = self.home / "tuttle.db" @@ -41,25 +45,32 @@ def __init__(self, home_dir=None, verbose=False, in_memory=False): # TODO: pass # setup DB - sqlmodel.SQLModel.metadata.create_all(self.db_engine) - self.db_session = self.get_session() + self.create_model() + self.db_session = self.create_session() # setup visual theme # TODO: by user settings dataviz.enable_theme("tuttle_dark") - def get_session(self): + def create_model(self): + logger.info("creating database model") + sqlmodel.SQLModel.metadata.create_all(self.db_engine, checkfirst=True) + + def create_session(self): return sqlmodel.Session( self.db_engine, expire_on_commit=False, ) + def get_session(self): + return self.db_session + def clear_database(self): """ Delete the database and rebuild database model. """ self.db_path.unlink() self.db_engine = sqlmodel.create_engine(f"sqlite:///{self.db_path}", echo=True) - sqlmodel.SQLModel.metadata.create_all(self.db_engine) + self.create_model() def store(self, entity): """Store an entity in the database.""" @@ -67,6 +78,12 @@ def store(self, entity): session.add(entity) session.commit() + def delete(self, entity): + """Delete an entity from the database.""" + with self.get_session() as session: + session.delete(entity) + session.commit() + def store_all(self, entities): """Store a collection of entities in the database.""" with self.get_session() as session: @@ -81,6 +98,24 @@ def retrieve_all(self, entity_type): ).all() return entities + @property + def contacts(self): + contacts = self.db_session.exec( + sqlmodel.select(model.Contact), + ).all() + return contacts + + def query(self, entity_type: Type[SQLModel]): + logger.debug(f"querying {entity_type}") + entities = self.db_session.exec( + sqlmodel.select(entity_type), + ).all() + if len(entities) == 0: + logger.warning("No instances of {entity_type} found") + else: + logger.info(f"Found {len(entities)} instances of {entity_type}") + return entities + @property def contracts(self): contracts = self.db_session.exec( diff --git a/tuttle/model.py b/tuttle/model.py index 398a7430..b5935bf5 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -156,6 +156,7 @@ class Contact(SQLModel, table=True): id: Optional[int] = Field(default=None, primary_key=True) name: str + company: Optional[str] email: Optional[str] address_id: Optional[int] = Field(default=None, foreign_key="address.id") address: Optional[Address] = Relationship(back_populates="contacts") @@ -164,6 +165,20 @@ class Contact(SQLModel, table=True): ) # post address + def print_address(self): + """Print address in common format.""" + if self.address is None: + return "" + return textwrap.dedent( + f""" + {self.name} + {self.company} + {self.address.street} {self.address.number} + {self.address.postal_code} {self.address.city} + {self.address.country} + """ + ) + class Client(SQLModel, table=True): """A client the freelancer has contracted with.""" diff --git a/tuttle_tests/conftest.py b/tuttle_tests/conftest.py index 0e0e2b6d..3d4bfa53 100644 --- a/tuttle_tests/conftest.py +++ b/tuttle_tests/conftest.py @@ -8,6 +8,21 @@ from tuttle.model import Project, Client, Address, Contact, User, BankAccount, Contract +@pytest.fixture +def demo_contact(): + return Contact( + name="Sam Lowry", + email="info@centralservices.com", + address=Address( + street="Main Street", + number="9999", + postal_code="55555", + city="Sao Paolo", + country="Brazil", + ), + ) + + @pytest.fixture def demo_user(): user = User( @@ -28,6 +43,7 @@ def demo_user(): bank_account=BankAccount( name="Giro", IBAN="BZ99830994950003161565", + BIC="BANKINFO101", ), ) return user diff --git a/tuttle_tests/demo.py b/tuttle_tests/demo.py new file mode 100644 index 00000000..3d1a0122 --- /dev/null +++ b/tuttle_tests/demo.py @@ -0,0 +1,173 @@ +import datetime + +from tuttle.model import ( + Contact, + Address, + User, + BankAccount, + Client, + Contract, + Project, +) +from tuttle import time, controller + +# USERS + +user = User( + name="Harry Tuttle", + subtitle="Heating Engineer", + website="https://tuttle-dev.github.io/tuttle/", + email="mail@tuttle.com", + phone_number="+55555555555", + VAT_number="27B-6", + address=Address( + name="Harry Tuttle", + street="Main Street", + number="450", + city="Somewhere", + postal_code="555555", + country="Brazil", + ), + bank_account=BankAccount( + name="Giro", + IBAN="BZ99830994950003161565", + BIC="BANKINFO101", + ), +) + +# CONTACTS + +contact_one = Contact( + name="Sam Lowry", + email="lowry@centralservices.com", + address=Address( + street="Main Street", + number="9999", + postal_code="55555", + city="Somewhere", + country="Brazil", + ), +) + +contact_two = Contact( + name="Jill Layton", + email="jilllayton@gmail.com", + address=None, +) + +contact_three = Contact( + name="Mr Kurtzman", + company="Central Services", + email="kurtzman@centralservices.com", + address=Address( + street="Main Street", + number="1111", + postal_code="55555", + city="Somewhere", + country="Brazil", + ), +) + +contact_four = Contact( + name="Harry Buttle", + company="Shoe Repairs Central", + address=Address( + street="Main Street", + number="8888", + postal_code="55555", + city="Somewhere", + country="Brazil", + ), +) + + +# CLIENTS + +client_one = Client( + name="Central Services", + invoicing_contact=Contact( + name="Central Services", + email="info@centralservices.com", + address=Address( + street="Main Street", + number="42", + postal_code="55555", + city="Somewhere", + country="Brazil", + ), + ), +) + +client_two = Client( + name="Sam Lowry", + invoicing_contact=contact_one, +) + +# CONTRACTS + +contract_one = Contract( + title="Heating Engineering Contract", + client=client_one, + rate=100.00, + currency="EUR", + unit=time.TimeUnit.hour, + units_per_workday=8, + term_of_payment=14, + billing_cycle=time.Cycle.monthly, + signature_date=datetime.date(2022, 2, 1), + start_date=datetime.date(2022, 2, 1), +) + +contract_two = Contract( + title="Heating Repair Contract", + client=client_two, + rate=50.00, + currency="EUR", + unit=time.TimeUnit.hour, + units_per_workday=8, + term_of_payment=14, + billing_cycle=time.Cycle.monthly, + signature_date=datetime.date(2022, 1, 1), + start_date=datetime.date(2022, 1, 1), +) + +# PROJECTS + +project_one = Project( + title="Heating Engineering", + tag="#HeatingEngineering", + contract=contract_one, + start_date=datetime.date(2022, 1, 1), + end_date=datetime.date(2022, 3, 31), +) + +project_two = Project( + title="Heating Repair", + tag="#HeatingRepair", + contract=contract_two, + start_date=datetime.date(2022, 1, 1), + end_date=datetime.date(2022, 3, 31), +) + + +def add_demo_data( + con: controller.Controller, +): + con.store(user) + con.store(contact_one) + con.store(contact_two) + con.store(contact_three) + con.store(contact_four) + con.store(client_one) + con.store(client_two) + con.store(contract_one) + con.store(contract_two) + con.store(project_one) + con.store(project_two) + + +if __name__ == "__main__": + con = controller.Controller( + in_memory=True, + ) + add_demo_data(con)