From 6d2e89ac53bebf0cf3754efbd57cf5ee1c7f235d Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 20 Aug 2022 12:10:39 +0200 Subject: [PATCH 01/16] minimal test --- app/gui.py | 95 ++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 1 + requirements_dev.txt | 1 + 3 files changed, 97 insertions(+) create mode 100644 app/gui.py diff --git a/app/gui.py b/app/gui.py new file mode 100644 index 00000000..723e5c7d --- /dev/null +++ b/app/gui.py @@ -0,0 +1,95 @@ +import flet +from flet import ( + Column, + FloatingActionButton, + Icon, + NavigationRail, + NavigationRailDestination, + Page, + Row, + Text, + VerticalDivider, + icons, + FilledTonalButton, + ElevatedButton, + IconButton, +) + +import tuttle + + +def main(page: Page): + + application = tuttle.app.App(home_dir=".demo_home") + + 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.AREA_CHART_OUTLINED, + selected_icon=icons.AREA_CHART, + label="Dashboard", + ), + NavigationRailDestination( + icon=icons.WORK_OUTLINED, + selected_icon=icons.WORK, + label="Projects", + ), + NavigationRailDestination( + icon=icons.EDIT_CALENDAR_OUTLINED, + selected_icon=icons.EDIT_CALENDAR, + label="Time", + ), + NavigationRailDestination( + icon=icons.ATTACH_EMAIL_OUTLINED, + selected_icon=icons.ATTACH_EMAIL, + label="Invoicing", + ), + NavigationRailDestination( + icon=icons.BUSINESS_OUTLINED, + selected_icon=icons.BUSINESS, + label="Banking", + ), + 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!"), + FilledTonalButton("Enabled button"), + FilledTonalButton("Disabled button", disabled=True), + ElevatedButton("Enabled button"), + ElevatedButton("Disabled button", disabled=True), + FloatingActionButton(icon=icons.CREATE, text="Action Button"), + IconButton( + icon=icons.CREATE, + tooltip="Create", + ), + ], + alignment="start", + expand=True, + ), + ], + expand=True, + ) + ) + + +flet.app(target=main) diff --git a/requirements.txt b/requirements.txt index 693d11d1..d6fe3472 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ ics babel loguru pdfkit +flet 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 From 03d547456094b7895e0ff792f5dbefc646cfd329 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 7 Sep 2022 10:53:22 +0200 Subject: [PATCH 02/16] experimentation with flet --- app/components/view_contact.py | 93 ++++++++++++++++++++ app/components/view_contract.py | 75 ++++++++++++++++ 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/icon_browser.py | 147 ++++++++++++++++++++++++++++++++ app/examples/list_tile.py | 80 +++++++++++++++++ app/gui.py | 67 +++++++++++---- app/main.py | 94 ++++++++++++++++++++ app/routing.py | 41 +++++++++ app/views.py | 0 tuttle/model.py | 1 + 14 files changed, 863 insertions(+), 16 deletions(-) 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/icon_browser.py create mode 100644 app/examples/list_tile.py create mode 100644 app/main.py create mode 100644 app/routing.py create mode 100644 app/views.py diff --git a/app/components/view_contact.py b/app/components/view_contact.py new file mode 100644 index 00000000..bc75bf24 --- /dev/null +++ b/app/components/view_contact.py @@ -0,0 +1,93 @@ +import flet +from flet import ( + UserControl, + Page, + View, + Text, + Column, + Row, + Icon, + Card, + Container, + ListTile, + IconButton, +) +from flet import icons + +from tuttle.model import ( + Contact, + Address, +) + + +class ContactView(UserControl): + """Main class of the application GUI.""" + + def __init__( + self, + contact: Contact, + ): + super().__init__() + self.contact = 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.contact.address.printed), + ] + ), + ), + Row( + [ + IconButton( + icon=icons.EDIT, + ), + IconButton( + icon=icons.DELETE, + ), + ], + alignment="end", + ), + ] + ), + # width=400, + padding=10, + ) + ) + return self.view + + +def main(page: Page): + + demo_contact = Contact( + name="Sam Lowry", + email="info@centralservices.com", + address=Address( + street="Main Street", + number="9999", + postal_code="55555", + city="Sao Paolo", + country="Brazil", + ), + ) + + page.add( + ContactView(demo_contact), + ContactView(demo_contact), + ) + + 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..5eab8374 --- /dev/null +++ b/app/components/view_contract.py @@ -0,0 +1,75 @@ +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, + ) + ) + + +def main(page: Page): + + page.add( + Row( + [ + navigation, + VerticalDivider(width=0), + ], + expand=True, + ) + ) + + page.update() + + +flet.app( + target=main, +) 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/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/gui.py b/app/gui.py index 723e5c7d..e83c3b81 100644 --- a/app/gui.py +++ b/app/gui.py @@ -1,5 +1,6 @@ import flet from flet import ( + View, Column, FloatingActionButton, Icon, @@ -13,6 +14,7 @@ FilledTonalButton, ElevatedButton, IconButton, + Switch, ) import tuttle @@ -22,6 +24,32 @@ def main(page: Page): application = tuttle.app.App(home_dir=".demo_home") + # NAVIGATION + + def route_change(route): + page.views.clear() + page.views.append( + View( + "/", + [], + ) + ) + if page.route == "/dashboard": + page.views.append( + View( + "/dashboard", + [Text("Dashboard")], + ) + ) + elif page.route == "/projects": + page.views.append( + View( + "/projects", + [Text("Projects")], + ) + ) + page.update() + rail = NavigationRail( selected_index=0, label_type="all", @@ -70,22 +98,29 @@ def main(page: Page): [ rail, VerticalDivider(width=1), - Column( - [ - Text("Body!"), - FilledTonalButton("Enabled button"), - FilledTonalButton("Disabled button", disabled=True), - ElevatedButton("Enabled button"), - ElevatedButton("Disabled button", disabled=True), - FloatingActionButton(icon=icons.CREATE, text="Action Button"), - IconButton( - icon=icons.CREATE, - tooltip="Create", - ), - ], - alignment="start", - expand=True, - ), + # Column( + # [ + # Text("Buttons"), + # FilledTonalButton("Enabled button"), + # FilledTonalButton("Disabled button", disabled=True), + # ElevatedButton("Enabled button"), + # ElevatedButton("Disabled button", disabled=True), + # FloatingActionButton(icon=icons.CREATE, text="Action Button"), + # IconButton( + # icon=icons.CREATE, + # tooltip="Create", + # ), + # ], + # alignment="start", + # expand=True, + # ), + # Column( + # [ + # Text("Switches"), + # Switch(label="Unchecked switch", value=False), + # Switch(label="Checked switch", value=True), + # ], + # ) ], expand=True, ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 00000000..33c7a36c --- /dev/null +++ b/app/main.py @@ -0,0 +1,94 @@ +import flet +from flet import ( + UserControl, + Page, + View, + Text, + Column, + Row, + KeyboardEvent, + SnackBar, + NavigationRail, + NavigationRailDestination, + VerticalDivider, + Icon, +) +from flet import icons + + +class App(UserControl): + """Main class of the application GUI.""" + + def __init__( + self, + ): + super().__init__() + + def build(self): + """Obligatory build method.""" + + self.navigation = 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.AREA_CHART_OUTLINED, + selected_icon=icons.AREA_CHART, + label="Dashboard", + ), + ], + on_change=lambda e: print( + "Selected destination:", e.control.selected_index + ), + ) + + return Row( + [ + self.navigation, + VerticalDivider(width=1), + ], + expand=True, + ) + + +def main(page: Page): + + navigation = 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.AREA_CHART_OUTLINED, + selected_icon=icons.AREA_CHART, + label="Dashboard", + ), + ], + on_change=lambda e: print("Selected destination:", e.control.selected_index), + ) + + page.add( + Row( + [ + navigation, + VerticalDivider(width=0), + ], + expand=True, + ) + ) + + page.update() + + +flet.app( + target=main, +) diff --git a/app/routing.py b/app/routing.py new file mode 100644 index 00000000..65b340c9 --- /dev/null +++ b/app/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/views.py b/app/views.py new file mode 100644 index 00000000..e69de29b diff --git a/tuttle/model.py b/tuttle/model.py index 398a7430..94e39421 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") From f55b956df0683bfe5326e13f66cc757a2f62a325 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 7 Sep 2022 10:58:40 +0200 Subject: [PATCH 03/16] minor --- app/components/view_contact.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/components/view_contact.py b/app/components/view_contact.py index bc75bf24..e991b85b 100644 --- a/app/components/view_contact.py +++ b/app/components/view_contact.py @@ -21,7 +21,7 @@ class ContactView(UserControl): - """Main class of the application GUI.""" + """View of the Contact model class.""" def __init__( self, From 3e90cb97348fedf087178e73afc507afe597d4f4 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 7 Sep 2022 16:39:22 +0200 Subject: [PATCH 04/16] MVC example --- app/components/view_contact.py | 65 +++++++++++++++++++++++++--------- tuttle/controller.py | 32 ++++++++++++++--- tuttle_tests/conftest.py | 15 ++++++++ tuttle_tests/demo_objects.py | 23 ++++++++++++ 4 files changed, 115 insertions(+), 20 deletions(-) create mode 100644 tuttle_tests/demo_objects.py diff --git a/app/components/view_contact.py b/app/components/view_contact.py index e991b85b..2f87f7d2 100644 --- a/app/components/view_contact.py +++ b/app/components/view_contact.py @@ -14,22 +14,54 @@ ) from flet import icons +from tuttle.controller import Controller from tuttle.model import ( Contact, Address, ) +from tuttle_tests.demo_objects import demo_contact, another_demo_contact -class ContactView(UserControl): + +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__() + 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( @@ -42,7 +74,7 @@ def build(self): subtitle=Column( [ Text(self.contact.email), - Text(self.contact.address.printed), + Text(self.get_address()), ] ), ), @@ -53,6 +85,7 @@ def build(self): ), IconButton( icon=icons.DELETE, + on_click=self.delete_contact, ), ], alignment="end", @@ -68,23 +101,23 @@ def build(self): def main(page: Page): - demo_contact = Contact( - name="Sam Lowry", - email="info@centralservices.com", - address=Address( - street="Main Street", - number="9999", - postal_code="55555", - city="Sao Paolo", - country="Brazil", - ), + con = Controller( + in_memory=True, + verbose=True, ) - page.add( - ContactView(demo_contact), - ContactView(demo_contact), + print(con.db_engine) + + con.store(demo_contact) + con.store(another_demo_contact) + + app = App( + con, ) + for contact in con.contacts: + page.add(ContactView(contact, app)) + page.update() diff --git a/tuttle/controller.py b/tuttle/controller.py index 040c25bf..e2a6f49b 100644 --- a/tuttle/controller.py +++ b/tuttle/controller.py @@ -6,6 +6,7 @@ import pandas import sqlmodel +from sqlmodel import pool from loguru import logger @@ -26,6 +27,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 +44,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 +77,13 @@ 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,13 @@ 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 + @property def contracts(self): contracts = self.db_session.exec( diff --git a/tuttle_tests/conftest.py b/tuttle_tests/conftest.py index 0e0e2b6d..590b535a 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( diff --git a/tuttle_tests/demo_objects.py b/tuttle_tests/demo_objects.py new file mode 100644 index 00000000..5570ffd6 --- /dev/null +++ b/tuttle_tests/demo_objects.py @@ -0,0 +1,23 @@ +from tuttle.model import ( + Contact, + Address, +) + +demo_contact = Contact( + name="Sam Lowry", + email="info@centralservices.com", + address=Address( + street="Main Street", + number="9999", + postal_code="55555", + city="Sao Paolo", + country="Brazil", + ), +) + +another_demo_contact = Contact( + name="Harry Tuttle", + company="Harry Tuttle - Heating Engineer", + email="harry@tuttle.com", + address=None, +) From 651d783704c7a501eb91afaf42efd7745a7fc0d0 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Wed, 7 Sep 2022 16:39:37 +0200 Subject: [PATCH 05/16] MVC example --- app/components/view_contact.py | 2 -- tuttle/controller.py | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/app/components/view_contact.py b/app/components/view_contact.py index 2f87f7d2..13d69fce 100644 --- a/app/components/view_contact.py +++ b/app/components/view_contact.py @@ -106,8 +106,6 @@ def main(page: Page): verbose=True, ) - print(con.db_engine) - con.store(demo_contact) con.store(another_demo_contact) diff --git a/tuttle/controller.py b/tuttle/controller.py index e2a6f49b..66d029ae 100644 --- a/tuttle/controller.py +++ b/tuttle/controller.py @@ -14,7 +14,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: @@ -80,7 +80,6 @@ def store(self, entity): def delete(self, entity): """Delete an entity from the database.""" with self.get_session() as session: - session.delete(entity) session.commit() From eee55b0f3f536d71d43f612052cfb8453c9524c8 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Thu, 8 Sep 2022 14:08:20 +0200 Subject: [PATCH 06/16] WIP: UI components --- app/components/navigation.py | 76 +++++++++++++++++++++++ app/components/select_time_tracking.py | 67 +++++++++++++++++++++ app/examples/file_upload.py | 41 +++++++++++++ app/examples/navigation_rail.py | 56 +++++++++++++++++ app/main.py | 80 +++++++++++++++++-------- app/views.py | 83 ++++++++++++++++++++++++++ 6 files changed, 377 insertions(+), 26 deletions(-) create mode 100644 app/components/navigation.py create mode 100644 app/components/select_time_tracking.py create mode 100644 app/examples/file_upload.py create mode 100644 app/examples/navigation_rail.py 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/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/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/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/main.py b/app/main.py index 33c7a36c..68d449a8 100644 --- a/app/main.py +++ b/app/main.py @@ -15,19 +15,44 @@ ) from flet import icons +from tuttle.controller import Controller + +from views import ( + ContactView, +) + class App(UserControl): - """Main class of the application GUI.""" + """Main application window.""" def __init__( self, + con: Controller, ): super().__init__() + self.con = con def build(self): + return Row( + [ + NavigationRail( + destinations=[ + NavigationRailDestination( + icon=icons.AREA_CHART_OUTLINED, + selected_icon=icons.AREA_CHART, + label="Dashboard", + ) + ], + extended=True, + ), + VerticalDivider(width=1), + ] + ) + + def build_(self): """Obligatory build method.""" - self.navigation = NavigationRail( + self.nav_rail = NavigationRail( selected_index=0, label_type="all", extended=True, @@ -49,7 +74,7 @@ def build(self): return Row( [ - self.navigation, + self.nav_rail, VerticalDivider(width=1), ], expand=True, @@ -58,32 +83,35 @@ def build(self): def main(page: Page): - navigation = 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.AREA_CHART_OUTLINED, - selected_icon=icons.AREA_CHART, - label="Dashboard", - ), - ], - on_change=lambda e: print("Selected destination:", e.control.selected_index), + con = Controller( + in_memory=True, + verbose=True, + ) + + app = App( + con, ) + # navigation = 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.AREA_CHART_OUTLINED, + # selected_icon=icons.AREA_CHART, + # label="Dashboard", + # ), + # ], + # on_change=lambda e: print("Selected destination:", e.control.selected_index), + # ) + page.add( - Row( - [ - navigation, - VerticalDivider(width=0), - ], - expand=True, - ) + app, ) page.update() diff --git a/app/views.py b/app/views.py index e69de29b..2f1e5bd6 100644 --- a/app/views.py +++ b/app/views.py @@ -0,0 +1,83 @@ +from flet import ( + UserControl, + Card, + Container, + Column, + Row, + ListTile, + Icon, + IconButton, + Text, +) +from flet import icons + +from tuttle.model import ( + Contact, +) + + +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 From 6ca10d51545628a199eb3e368c111dc40dd55086 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Thu, 8 Sep 2022 15:02:18 +0200 Subject: [PATCH 07/16] removed requirement fints to make pyinstaller work --- app/{ => components}/routing.py | 0 app/gui.py | 130 -------------------------------- app/main.py | 72 +++++++++++++----- requirements.txt | 1 - tuttle/banking.py | 47 ++++++------ 5 files changed, 77 insertions(+), 173 deletions(-) rename app/{ => components}/routing.py (100%) delete mode 100644 app/gui.py diff --git a/app/routing.py b/app/components/routing.py similarity index 100% rename from app/routing.py rename to app/components/routing.py diff --git a/app/gui.py b/app/gui.py deleted file mode 100644 index e83c3b81..00000000 --- a/app/gui.py +++ /dev/null @@ -1,130 +0,0 @@ -import flet -from flet import ( - View, - Column, - FloatingActionButton, - Icon, - NavigationRail, - NavigationRailDestination, - Page, - Row, - Text, - VerticalDivider, - icons, - FilledTonalButton, - ElevatedButton, - IconButton, - Switch, -) - -import tuttle - - -def main(page: Page): - - application = tuttle.app.App(home_dir=".demo_home") - - # NAVIGATION - - def route_change(route): - page.views.clear() - page.views.append( - View( - "/", - [], - ) - ) - if page.route == "/dashboard": - page.views.append( - View( - "/dashboard", - [Text("Dashboard")], - ) - ) - elif page.route == "/projects": - page.views.append( - View( - "/projects", - [Text("Projects")], - ) - ) - page.update() - - 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.AREA_CHART_OUTLINED, - selected_icon=icons.AREA_CHART, - label="Dashboard", - ), - NavigationRailDestination( - icon=icons.WORK_OUTLINED, - selected_icon=icons.WORK, - label="Projects", - ), - NavigationRailDestination( - icon=icons.EDIT_CALENDAR_OUTLINED, - selected_icon=icons.EDIT_CALENDAR, - label="Time", - ), - NavigationRailDestination( - icon=icons.ATTACH_EMAIL_OUTLINED, - selected_icon=icons.ATTACH_EMAIL, - label="Invoicing", - ), - NavigationRailDestination( - icon=icons.BUSINESS_OUTLINED, - selected_icon=icons.BUSINESS, - label="Banking", - ), - 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("Buttons"), - # FilledTonalButton("Enabled button"), - # FilledTonalButton("Disabled button", disabled=True), - # ElevatedButton("Enabled button"), - # ElevatedButton("Disabled button", disabled=True), - # FloatingActionButton(icon=icons.CREATE, text="Action Button"), - # IconButton( - # icon=icons.CREATE, - # tooltip="Create", - # ), - # ], - # alignment="start", - # expand=True, - # ), - # Column( - # [ - # Text("Switches"), - # Switch(label="Unchecked switch", value=False), - # Switch(label="Checked switch", value=True), - # ], - # ) - ], - expand=True, - ) - ) - - -flet.app(target=main) diff --git a/app/main.py b/app/main.py index 68d449a8..1923cfe3 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ VerticalDivider, Icon, ) -from flet import icons +from flet import icons, colors from tuttle.controller import Controller @@ -92,26 +92,60 @@ def main(page: Page): con, ) - # navigation = 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.AREA_CHART_OUTLINED, - # selected_icon=icons.AREA_CHART, - # label="Dashboard", - # ), - # ], - # on_change=lambda e: print("Selected destination:", e.control.selected_index), - # ) + 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( - app, + Row( + [ + rail, + # VerticalDivider(width=1), + Column([Text("Body!")], alignment="start", expand=True), + ], + expand=True, + ) ) page.update() diff --git a/requirements.txt b/requirements.txt index c1e763ca..c9857286 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,7 +5,6 @@ pydantic sqlalchemy == 1.4.35 sqlmodel pyicloud -fints ics babel loguru 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 From 81973c896cbe71c805c36dc2187abd24f06674af Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Thu, 8 Sep 2022 15:10:08 +0200 Subject: [PATCH 08/16] rename app main file --- app/{main.py => Tuttle.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename app/{main.py => Tuttle.py} (100%) diff --git a/app/main.py b/app/Tuttle.py similarity index 100% rename from app/main.py rename to app/Tuttle.py From a8638f32bbe7b7cf7937d13fb04c2b5ef69c8b89 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Thu, 8 Sep 2022 17:21:06 +0200 Subject: [PATCH 09/16] demo content --- app/Tuttle.py | 98 ++++++++++--------- app/components/view_contact.py | 4 +- tuttle/controller.py | 9 +- tuttle_tests/conftest.py | 1 + tuttle_tests/demo.py | 173 +++++++++++++++++++++++++++++++++ tuttle_tests/demo_objects.py | 23 ----- 6 files changed, 236 insertions(+), 72 deletions(-) create mode 100644 tuttle_tests/demo.py delete mode 100644 tuttle_tests/demo_objects.py diff --git a/app/Tuttle.py b/app/Tuttle.py index 1923cfe3..9c88ce2e 100644 --- a/app/Tuttle.py +++ b/app/Tuttle.py @@ -16,90 +16,94 @@ from flet import icons, colors from tuttle.controller import Controller +from tuttle.model import ( + Contact, +) from views import ( ContactView, ) +class ContactsView(UserControl): + def __init__( + self, + app: "App", + ): + super().__init__() + self.app = app + + def build(self): + + contacts = self.app.con.query(Contact) + + return Text(f"contacts: {' '.join(contacts)}") + + class App(UserControl): """Main application window.""" def __init__( self, con: Controller, + page: Page, ): super().__init__() self.con = con + self.page = page def build(self): - return Row( - [ - NavigationRail( - destinations=[ - NavigationRailDestination( - icon=icons.AREA_CHART_OUTLINED, - selected_icon=icons.AREA_CHART, - label="Dashboard", - ) - ], - extended=True, - ), - VerticalDivider(width=1), - ] - ) - def build_(self): - """Obligatory build method.""" - - self.nav_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.AREA_CHART_OUTLINED, - selected_icon=icons.AREA_CHART, - label="Dashboard", - ), - ], - on_change=lambda e: print( - "Selected destination:", e.control.selected_index - ), - ) + self.contacts_view = ContactsView(self) - return Row( + self.main_view = Column( [ - self.nav_rail, - VerticalDivider(width=1), + Text("Main application window"), ], + alignment="start", expand=True, ) + return self.main_view + + def attach_navigation(self, nav: NavigationRail): + # FIXME: workaround + self.nav = nav + + def on_navigation_change(self, event): + print(event.control.selected_index) + self.main_view.controls.clear() + if event.control.selected_index == 3: + self.main_view.controls.append(self.contacts_view) + else: + self.main_view.controls.append( + Text(f"selected destination {event.control.selected_index}") + ) + self.update() + + def update(self): + super().update() def main(page: Page): con = Controller( in_memory=True, - verbose=True, + verbose=False, ) app = App( con, + page, ) - rail = NavigationRail( + nav = 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, + # bgcolor=colors.BLUE_GREY_900, destinations=[ NavigationRailDestination( icon=icons.SPEED, @@ -134,15 +138,17 @@ def main(page: Page): label_content=Text("Settings"), ), ], - on_change=lambda e: print("Selected destination:", e.control.selected_index), + on_change=app.on_navigation_change, ) + app.attach_navigation(nav) + page.add( Row( [ - rail, + nav, # VerticalDivider(width=1), - Column([Text("Body!")], alignment="start", expand=True), + app, ], expand=True, ) diff --git a/app/components/view_contact.py b/app/components/view_contact.py index 13d69fce..0da1cff6 100644 --- a/app/components/view_contact.py +++ b/app/components/view_contact.py @@ -20,7 +20,7 @@ Address, ) -from tuttle_tests.demo_objects import demo_contact, another_demo_contact +from tuttle_tests.demo import demo_contact, contact_two class App(UserControl): @@ -107,7 +107,7 @@ def main(page: Page): ) con.store(demo_contact) - con.store(another_demo_contact) + con.store(contact_two) app = App( con, diff --git a/tuttle/controller.py b/tuttle/controller.py index 66d029ae..e94c3db7 100644 --- a/tuttle/controller.py +++ b/tuttle/controller.py @@ -3,10 +3,11 @@ import os import sys import datetime +from typing import Type import pandas import sqlmodel -from sqlmodel import pool +from sqlmodel import pool, SQLModel from loguru import logger @@ -104,6 +105,12 @@ def contacts(self): ).all() return contacts + def query(self, entity_type: Type[SQLModel]): + entities = self.db_session.exec( + sqlmodel.select(entity_type), + ).all() + return entities + @property def contracts(self): contracts = self.db_session.exec( diff --git a/tuttle_tests/conftest.py b/tuttle_tests/conftest.py index 590b535a..3d4bfa53 100644 --- a/tuttle_tests/conftest.py +++ b/tuttle_tests/conftest.py @@ -43,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..86ab382d --- /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_content( + 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_content(con) diff --git a/tuttle_tests/demo_objects.py b/tuttle_tests/demo_objects.py deleted file mode 100644 index 5570ffd6..00000000 --- a/tuttle_tests/demo_objects.py +++ /dev/null @@ -1,23 +0,0 @@ -from tuttle.model import ( - Contact, - Address, -) - -demo_contact = Contact( - name="Sam Lowry", - email="info@centralservices.com", - address=Address( - street="Main Street", - number="9999", - postal_code="55555", - city="Sao Paolo", - country="Brazil", - ), -) - -another_demo_contact = Contact( - name="Harry Tuttle", - company="Harry Tuttle - Heating Engineer", - email="harry@tuttle.com", - address=None, -) From e062ea4002bfdff0a2eb8f9e84528b6f1ad126b5 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Thu, 8 Sep 2022 19:23:34 +0200 Subject: [PATCH 10/16] displaying demo objects --- app/Tuttle.py | 54 ++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/app/Tuttle.py b/app/Tuttle.py index 9c88ce2e..34fb9a9e 100644 --- a/app/Tuttle.py +++ b/app/Tuttle.py @@ -12,20 +12,29 @@ NavigationRailDestination, VerticalDivider, Icon, + ListTile, + ListView, + Card, + Container, ) from flet import icons, colors from tuttle.controller import Controller from tuttle.model import ( Contact, + Contract, ) from views import ( ContactView, ) +from tuttle_tests import demo -class ContactsView(UserControl): +from loguru import logger + + +class ContactsPage(UserControl): def __init__( self, app: "App", @@ -33,11 +42,20 @@ def __init__( super().__init__() self.app = app + def update(self): + super().update() + def build(self): contacts = self.app.con.query(Contact) - return Text(f"contacts: {' '.join(contacts)}") + self.main_view = Column() + + for contact in contacts: + print(contact.name) + self.main_view.controls.append(Text(contact.name)) + + return self.main_view class App(UserControl): @@ -54,8 +72,30 @@ def __init__( def build(self): - self.contacts_view = ContactsView(self) + # contacts page + self.contacts_page = Column() + + contacts = self.con.query(Contact) + logger.debug(f"{len(contacts)} contacts found") + for contact in contacts: + self.contacts_page.controls.append( + Card( + Text(contact.name), + ), + ) + + # contracts page + self.contracts_page = Column() + contracts = self.con.query(Contract) + logger.debug(f"{len(contracts)} contracts found") + for contract in contracts: + self.contracts_page.controls.append( + Card( + Text(contract.title), + ), + ) + # main view self.main_view = Column( [ Text("Main application window"), @@ -73,7 +113,9 @@ def on_navigation_change(self, event): print(event.control.selected_index) self.main_view.controls.clear() if event.control.selected_index == 3: - self.main_view.controls.append(self.contacts_view) + self.main_view.controls.append(self.contacts_page) + elif event.control.selected_index == 5: + self.main_view.controls.append(self.contracts_page) else: self.main_view.controls.append( Text(f"selected destination {event.control.selected_index}") @@ -91,6 +133,9 @@ def main(page: Page): verbose=False, ) + # add demo content + demo.add_demo_content(con) + app = App( con, page, @@ -147,7 +192,6 @@ def main(page: Page): Row( [ nav, - # VerticalDivider(width=1), app, ], expand=True, From b717c2a39f4df3fefde6e9444fde18fe7fe24c06 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Fri, 9 Sep 2022 16:48:10 +0200 Subject: [PATCH 11/16] WIP: desktop app layout --- .vscode/settings.json | 3 +- app/components/menu_layout.py | 251 +++++++++++++++ app/examples/responsive_menu_layout.py | 403 +++++++++++++++++++++++++ 3 files changed, 656 insertions(+), 1 deletion(-) create mode 100644 app/components/menu_layout.py create mode 100644 app/examples/responsive_menu_layout.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/components/menu_layout.py b/app/components/menu_layout.py new file mode 100644 index 00000000..2ddaf12b --- /dev/null +++ b/app/components/menu_layout.py @@ -0,0 +1,251 @@ +from copy import deepcopy + +import flet +from flet import ( + AppBar, + Column, + Row, + Container, + IconButton, + Icon, + NavigationRail, + NavigationRailDestination, + Page, + Text, + Card, +) +from flet import colors, icons + + +class NavigationItem: + """An item in the NavigationRail.""" + + def __init__( + self, + name, + icon: Icon, + selected_icon: Icon = None, + ): + self.name = name + self.icon = icon + self.selected_icon = selected_icon + + +class MenuLayout(Row): + """A desktop app layout with a menu on the left.""" + + def __init__( + self, + page, + pages, + *args, + **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 + + page_contents = [page_content for _, page_content in pages] + + self.menu_panel = Row( + controls=[ + self.navigation_rail, + ], + 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() + + 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() + + 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): + navigation_items = self.navigation_items + + self.navigation_rail.destinations = [ + NavigationRailDestination(**nav_specs) for nav_specs in navigation_items + ] + self.navigation_rail.label_type = "all" + + def handle_resize(self, e): + pass + + def set_navigation_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_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"): + + page.title = title + page.window_width, page.window_height = (1280, 720) + + 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.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 = MenuLayout(page, pages) + + page.add(menu_layout) + + page.appbar.actions = [ + Row( + [ + IconButton( + icon=icons.HELP, + ) + ] + ) + ] + + +if __name__ == "__main__": + 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) From 03c4fe994446eb7a9aa74e9775faab5cd2655b33 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 10 Sep 2022 18:50:44 +0200 Subject: [PATCH 12/16] WIP: app layout --- app/Tuttle.py | 2 +- app/Tuttle2.py | 272 ++++++++++++++++++++++++++++++++++ app/components/menu_layout.py | 123 ++++++--------- app/examples/list_view.py | 26 ++++ app/layout.py | 219 +++++++++++++++++++++++++++ app/views.py | 99 +++++++++++++ tuttle/controller.py | 5 + tuttle/model.py | 14 ++ tuttle_tests/demo.py | 4 +- 9 files changed, 682 insertions(+), 82 deletions(-) create mode 100644 app/Tuttle2.py create mode 100644 app/examples/list_view.py create mode 100644 app/layout.py diff --git a/app/Tuttle.py b/app/Tuttle.py index 34fb9a9e..29ca3b9b 100644 --- a/app/Tuttle.py +++ b/app/Tuttle.py @@ -134,7 +134,7 @@ def main(page: Page): ) # add demo content - demo.add_demo_content(con) + demo.add_demo_data(con) app = App( con, diff --git a/app/Tuttle2.py b/app/Tuttle2.py new file mode 100644 index 00000000..45efe0c7 --- /dev/null +++ b/app/Tuttle2.py @@ -0,0 +1,272 @@ +from loguru import logger + +import flet +from flet import ( + Page, + Row, + Column, + Container, + Text, + Card, + NavigationRailDestination, + UserControl, + ElevatedButton, + TextButton, + Icon, +) +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( + [ + # Contacts listed here + ], + ) + self.view = Row([self.main_column]) + + return self.view + + 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.view.controls[0].controls.clear() + self.view.controls[0].controls.append( + Text("Demo data installed ☑️"), + ) + self.update() + + def build(self): + self.view = Row( + [ + Column( + [ + ElevatedButton( + "Install demo data", + icon=icons.TOYS, + on_click=self.add_demo_data, + ), + ], + ) + ] + ) + return self.view + + +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() + + +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", + ), + AppPage(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="Invoices", + ), + AppPage(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 index 2ddaf12b..04de2f58 100644 --- a/app/components/menu_layout.py +++ b/app/components/menu_layout.py @@ -13,32 +13,21 @@ Page, Text, Card, + Divider, ) from flet import colors, icons -class NavigationItem: - """An item in the NavigationRail.""" - - def __init__( - self, - name, - icon: Icon, - selected_icon: Icon = None, - ): - self.name = name - self.icon = icon - self.selected_icon = selected_icon - - 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) @@ -54,8 +43,6 @@ def __init__( self._menu_extended = True self.navigation_rail.extended = True - page_contents = [page_content for _, page_content in pages] - self.menu_panel = Row( controls=[ self.navigation_rail, @@ -63,17 +50,26 @@ def __init__( 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_navigation_content() + 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() @@ -96,17 +92,13 @@ def build_navigation_rail(self): ) def update_destinations(self): - navigation_items = self.navigation_items - - self.navigation_rail.destinations = [ - NavigationRailDestination(**nav_specs) for nav_specs in navigation_items - ] + self.navigation_rail.destinations = self.navigation_items self.navigation_rail.label_type = "all" def handle_resize(self, e): pass - def set_navigation_content(self): + def set_content(self): self.controls = [self.menu_panel, self.content_area] self.update_destinations() self.navigation_rail.extended = self._menu_extended @@ -121,6 +113,26 @@ 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( @@ -140,18 +152,9 @@ def create_page(title: str, body: str): def main(page: Page, title="Basic Responsive Menu"): - page.title = title - page.window_width, page.window_height = (1280, 720) - - page.appbar = AppBar( - # leading=menu_button, - # leading_width=40, - # bgcolor=colors.SURFACE_VARIANT, - ) - pages = [ ( - dict( + NavigationRailDestination( icon=icons.LANDSCAPE_OUTLINED, selected_icon=icons.LANDSCAPE, label="Menu in landscape", @@ -163,7 +166,7 @@ def main(page: Page, title="Basic Responsive Menu"): ), ), ( - dict( + NavigationRailDestination( icon=icons.PORTRAIT_OUTLINED, selected_icon=icons.PORTRAIT, label="Menu in portrait", @@ -179,7 +182,7 @@ def main(page: Page, title="Basic Responsive Menu"): ), ), ( - dict( + NavigationRailDestination( icon=icons.INSERT_EMOTICON_OUTLINED, selected_icon=icons.INSERT_EMOTICON, label="Minimize to icons", @@ -195,55 +198,17 @@ def main(page: Page, title="Basic Responsive Menu"): "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.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 = MenuLayout(page, pages) + menu_layout = MenuLayout( + page=page, + pages=pages, + title="Basic Desktop App Layout", + window_size=(1280, 720), + ) page.add(menu_layout) - page.appbar.actions = [ - Row( - [ - IconButton( - icon=icons.HELP, - ) - ] - ) - ] - if __name__ == "__main__": flet.app( 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/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 index 2f1e5bd6..c1e4ce94 100644 --- a/app/views.py +++ b/app/views.py @@ -13,6 +13,7 @@ from tuttle.model import ( Contact, + Contract, ) @@ -81,3 +82,101 @@ def build(self): ) ) return self.view + + +class ContactView2(Card): + def __init__( + self, + contact: Contact, + app: "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, + ) + ) diff --git a/tuttle/controller.py b/tuttle/controller.py index e94c3db7..f3b06588 100644 --- a/tuttle/controller.py +++ b/tuttle/controller.py @@ -106,9 +106,14 @@ def contacts(self): 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 diff --git a/tuttle/model.py b/tuttle/model.py index 94e39421..b5935bf5 100644 --- a/tuttle/model.py +++ b/tuttle/model.py @@ -165,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/demo.py b/tuttle_tests/demo.py index 86ab382d..3d1a0122 100644 --- a/tuttle_tests/demo.py +++ b/tuttle_tests/demo.py @@ -150,7 +150,7 @@ ) -def add_demo_content( +def add_demo_data( con: controller.Controller, ): con.store(user) @@ -170,4 +170,4 @@ def add_demo_content( con = controller.Controller( in_memory=True, ) - add_demo_content(con) + add_demo_data(con) From a0198c5f0804edb566c77e005d8ebc6aba89ed4a Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 10 Sep 2022 19:13:38 +0200 Subject: [PATCH 13/16] WIP: app layout --- app/Tuttle2.py | 2 +- app/views.py | 42 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/app/Tuttle2.py b/app/Tuttle2.py index 45efe0c7..49078655 100644 --- a/app/Tuttle2.py +++ b/app/Tuttle2.py @@ -208,7 +208,7 @@ def main(page: Page): icon=icons.WORK, label="Projects", ), - AppPage(app), + ProjectsPage(app), ), ( NavigationRailDestination( diff --git a/app/views.py b/app/views.py index c1e4ce94..093e6cdf 100644 --- a/app/views.py +++ b/app/views.py @@ -8,12 +8,15 @@ Icon, IconButton, Text, + PopupMenuButton, + PopupMenuItem, ) from flet import icons from tuttle.model import ( Contact, Contract, + Project, ) @@ -180,3 +183,42 @@ def make_contract_view(contract: Contract): 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.WARNING, + 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, + ) + ) From 7e404ebdf5b939996af2341fa76bedbfb8df1710 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 10 Sep 2022 19:44:03 +0200 Subject: [PATCH 14/16] WIP: app layout --- app/Tuttle.py | 290 ++++++++++++++++++++++++++--------------- app/Tuttle2.py | 272 -------------------------------------- app/examples/footer.py | 21 +++ app/views.py | 2 +- 4 files changed, 204 insertions(+), 381 deletions(-) delete mode 100644 app/Tuttle2.py create mode 100644 app/examples/footer.py diff --git a/app/Tuttle.py b/app/Tuttle.py index 29ca3b9b..bcf6de92 100644 --- a/app/Tuttle.py +++ b/app/Tuttle.py @@ -1,130 +1,186 @@ +from loguru import logger + import flet from flet import ( - UserControl, Page, - View, - Text, - Column, Row, - KeyboardEvent, - SnackBar, - NavigationRail, + Column, + Container, + Text, + Card, NavigationRailDestination, - VerticalDivider, + UserControl, + ElevatedButton, + TextButton, Icon, - ListTile, - ListView, - Card, - Container, ) 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, -) - -from views import ( - ContactView, + Project, + Client, ) from tuttle_tests import demo -from loguru import logger + +class App: + def __init__( + self, + controller: Controller, + page: Page, + ): + self.con = controller + self.page = page -class ContactsPage(UserControl): +class AppPage(UserControl): def __init__( self, - app: "App", + 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 - contacts = self.app.con.query(Contact) - self.main_view = Column() +class ContactsPage(AppPage): + def __init__( + self, + app: App, + ): + super().__init__(app) - for contact in contacts: - print(contact.name) - self.main_view.controls.append(Text(contact.name)) + def update(self): + super().update() - return self.main_view + 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 App(UserControl): - """Main application window.""" +class ContractsPage(AppPage): def __init__( self, - con: Controller, - page: Page, + app: App, ): - super().__init__() - self.con = con - self.page = page + super().__init__(app) - def build(self): + def update(self): + super().update() - # contacts page - self.contacts_page = Column() + def update_content(self): + super().update_content() + self.main_column.controls.clear() - contacts = self.con.query(Contact) - logger.debug(f"{len(contacts)} contacts found") - for contact in contacts: - self.contacts_page.controls.append( - Card( - Text(contact.name), - ), - ) + contracts = self.app.con.query(Contract) - # contracts page - self.contracts_page = Column() - contracts = self.con.query(Contract) - logger.debug(f"{len(contracts)} contracts found") for contract in contracts: - self.contracts_page.controls.append( - Card( - Text(contract.title), - ), + self.main_column.controls.append( + # TODO: replace with view class + views.make_contract_view(contract) ) + self.update() - # main view - self.main_view = Column( - [ - Text("Main application window"), - ], - alignment="start", - expand=True, - ) - return self.main_view - - def attach_navigation(self, nav: NavigationRail): - # FIXME: workaround - self.nav = nav - - def on_navigation_change(self, event): - print(event.control.selected_index) - self.main_view.controls.clear() - if event.control.selected_index == 3: - self.main_view.controls.append(self.contacts_page) - elif event.control.selected_index == 5: - self.main_view.controls.append(self.contracts_page) - else: - self.main_view.controls.append( - Text(f"selected destination {event.control.selected_index}") + +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() + def main(page: Page): @@ -133,74 +189,92 @@ def main(page: Page): verbose=False, ) - # add demo content - demo.add_demo_data(con) - app = App( - con, - page, + controller=con, + page=page, ) - nav = 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=[ + pages = [ + ( NavigationRailDestination( - icon=icons.SPEED, + 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 Tracking", + 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="Invoices", + label="Invocing", ), + InvoicingPage(app), + ), + ( NavigationRailDestination( icon=icons.SETTINGS, label_content=Text("Settings"), ), - ], - on_change=app.on_navigation_change, + AppPage(app), + ), + ] + + layout = DesktopAppLayout( + page=page, + pages=pages, + title="Tuttle", + window_size=(1280, 720), ) - app.attach_navigation(nav) - page.add( - Row( - [ - nav, - app, - ], - expand=True, - ) + layout, ) - page.update() - -flet.app( - target=main, -) +if __name__ == "__main__": + flet.app( + target=main, + ) diff --git a/app/Tuttle2.py b/app/Tuttle2.py deleted file mode 100644 index 49078655..00000000 --- a/app/Tuttle2.py +++ /dev/null @@ -1,272 +0,0 @@ -from loguru import logger - -import flet -from flet import ( - Page, - Row, - Column, - Container, - Text, - Card, - NavigationRailDestination, - UserControl, - ElevatedButton, - TextButton, - Icon, -) -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( - [ - # Contacts listed here - ], - ) - self.view = Row([self.main_column]) - - return self.view - - 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.view.controls[0].controls.clear() - self.view.controls[0].controls.append( - Text("Demo data installed ☑️"), - ) - self.update() - - def build(self): - self.view = Row( - [ - Column( - [ - ElevatedButton( - "Install demo data", - icon=icons.TOYS, - on_click=self.add_demo_data, - ), - ], - ) - ] - ) - return self.view - - -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() - - -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="Invoices", - ), - AppPage(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/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/views.py b/app/views.py index 093e6cdf..196ec2ff 100644 --- a/app/views.py +++ b/app/views.py @@ -195,7 +195,7 @@ def make_project_view(project: Project): title=Text(project.title), subtitle=Text(project.tag), trailing=PopupMenuButton( - icon=icons.WARNING, + icon=icons.MORE_VERT, items=[ PopupMenuItem( icon=icons.EDIT, From 99b492f06841c968566e21c1f191081e660bde6c Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sat, 10 Sep 2022 20:00:08 +0200 Subject: [PATCH 15/16] WIP: invoicing page --- app/Tuttle.py | 22 ++++++++++++++++++++++ app/examples/dropdown.py | 20 ++++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 app/examples/dropdown.py diff --git a/app/Tuttle.py b/app/Tuttle.py index bcf6de92..6e714c84 100644 --- a/app/Tuttle.py +++ b/app/Tuttle.py @@ -13,6 +13,8 @@ ElevatedButton, TextButton, Icon, + Dropdown, + dropdown, ) from flet import icons, colors @@ -181,6 +183,26 @@ def update(self): 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): 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) From 1f040e637fab44d3972e93b3790e0ebbd0e3c907 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 11 Sep 2022 11:49:22 +0200 Subject: [PATCH 16/16] prepare for merge --- app/components/view_contract.py | 20 -------------------- app/views.py | 6 +++--- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/app/components/view_contract.py b/app/components/view_contract.py index 5eab8374..8bc2729d 100644 --- a/app/components/view_contract.py +++ b/app/components/view_contract.py @@ -53,23 +53,3 @@ def build(self): padding=10, ) ) - - -def main(page: Page): - - page.add( - Row( - [ - navigation, - VerticalDivider(width=0), - ], - expand=True, - ) - ) - - page.update() - - -flet.app( - target=main, -) diff --git a/app/views.py b/app/views.py index 196ec2ff..72c9a362 100644 --- a/app/views.py +++ b/app/views.py @@ -23,7 +23,7 @@ class AppView(UserControl): def __init__( self, - app: "App", + app, ): super().__init__() self.app = app @@ -35,7 +35,7 @@ class ContactView(AppView): def __init__( self, contact: Contact, - app: "App", + app, ): super().__init__(app) self.contact = contact @@ -91,7 +91,7 @@ class ContactView2(Card): def __init__( self, contact: Contact, - app: "App", + app, ): super().__init__() self.contact = contact