From 426305498aa0219b58271aebe3e9688e0243fa10 Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Sun, 8 Mar 2026 10:11:24 +0100 Subject: [PATCH 01/11] refactor: update design tokens for improved macOS compatibility - Adjusted color values in colors.py to align with Apple Human Interface Guidelines. - Enhanced text colors for better readability in dark mode. - Added new status colors for project tracking. - Modified dimensions in dimens.py for a more spacious layout. - Increased font sizes in fonts.py for improved readability and hierarchy. - Updated theme visual density to standard for a more balanced UI. - Changed TimeTrackingView heading size for better prominence. - Expanded test coverage in test_app_start.py to include new client and contact views. - Added resource-module attribute checks to ensure consistency in UI constants. - Implemented runtime tests for new Client and Contact side panels to validate content building methods. --- app.py | 34 -- tuttle/app/clients/view.py | 524 +++++++++++++---- tuttle/app/contacts/view.py | 442 ++++++++++---- tuttle/app/contracts/view.py | 991 +++++++++++++++++--------------- tuttle/app/core/status_bar.py | 296 ++++++++++ tuttle/app/core/views.py | 561 +++++++++++++++--- tuttle/app/home/view.py | 176 ++++-- tuttle/app/invoicing/view.py | 4 +- tuttle/app/projects/view.py | 718 +++++++++++++---------- tuttle/app/res/colors.py | 44 +- tuttle/app/res/dimens.py | 18 +- tuttle/app/res/fonts.py | 23 +- tuttle/app/res/theme.py | 2 +- tuttle/app/timetracking/view.py | 2 +- tuttle_tests/test_app_start.py | 461 ++++++++++++++- 15 files changed, 3109 insertions(+), 1187 deletions(-) create mode 100644 tuttle/app/core/status_bar.py diff --git a/app.py b/app.py index 6f45e52..c985482 100644 --- a/app.py +++ b/app.py @@ -17,7 +17,6 @@ from tuttle.app.auth.view import ProfileScreen, SplashScreen -from tuttle.app.contracts.view import ContractEditorScreen, ViewContractScreen from tuttle.app.core.abstractions import TView, TViewParams from tuttle.app.core.client_storage_impl import ClientStorageImpl from tuttle.app.core.database_storage_impl import DatabaseStorageImpl @@ -29,7 +28,6 @@ from tuttle.app.preferences.intent import PreferencesIntent from tuttle.app.preferences.model import PreferencesStorageKeys from tuttle.app.preferences.view import PreferencesScreen -from tuttle.app.projects.view import ProjectEditorScreen, ViewProjectScreen from tuttle.app.res.colors import ( accent, bg, @@ -46,13 +44,9 @@ from tuttle.app.res.dimens import MIN_WINDOW_HEIGHT, MIN_WINDOW_WIDTH from tuttle.app.res.fonts import APP_FONTS, HEADLINE_4_SIZE, HEADLINE_FONT from tuttle.app.res.res_utils import ( - CONTRACT_DETAILS_SCREEN_ROUTE, - CONTRACT_EDITOR_SCREEN_ROUTE, HOME_SCREEN_ROUTE, PREFERENCES_SCREEN_ROUTE, PROFILE_SCREEN_ROUTE, - PROJECT_DETAILS_SCREEN_ROUTE, - PROJECT_EDITOR_SCREEN_ROUTE, SPLASH_SCREEN_ROUTE, ) from tuttle.app.res.theme import APP_THEME, THEME_MODES, get_theme_mode_from_value @@ -338,40 +332,12 @@ def parse_route(self, pageRoute: str): screen = ProfileScreen( params=self.tuttle_view_params, ) - elif routePath.match(CONTRACT_EDITOR_SCREEN_ROUTE): - screen = ContractEditorScreen(params=self.tuttle_view_params) - elif routePath.match(f"{CONTRACT_DETAILS_SCREEN_ROUTE}/:contractId"): - screen = ViewContractScreen( - params=self.tuttle_view_params, contract_id=routePath.contractId - ) - elif routePath.match(f"{CONTRACT_EDITOR_SCREEN_ROUTE}/:contractId"): - contractId = None - if hasattr(routePath, "contractId"): - contractId = routePath.contractId - screen = ContractEditorScreen( - params=self.tuttle_view_params, contract_id_if_editing=contractId - ) elif routePath.match(PREFERENCES_SCREEN_ROUTE): screen = PreferencesScreen( params=self.tuttle_view_params, on_theme_changed_callback=self.on_theme_changed, on_reset_app_callback=self.on_reset_and_quit, ) - elif routePath.match(PROJECT_EDITOR_SCREEN_ROUTE): - screen = ProjectEditorScreen(params=self.tuttle_view_params) - elif routePath.match(f"{PROJECT_DETAILS_SCREEN_ROUTE}/:projectId"): - screen = ViewProjectScreen( - params=self.tuttle_view_params, project_id=routePath.projectId - ) - elif routePath.match(PROJECT_EDITOR_SCREEN_ROUTE) or routePath.match( - f"{PROJECT_EDITOR_SCREEN_ROUTE}/:projectId" - ): - projectId = None - if hasattr(routePath, "projectId"): - projectId = routePath.projectId - screen = ProjectEditorScreen( - params=self.tuttle_view_params, project_id_if_editing=projectId - ) else: screen = Error404Screen(params=self.tuttle_view_params) diff --git a/tuttle/app/clients/view.py b/tuttle/app/clients/view.py index 92732dc..363af44 100644 --- a/tuttle/app/clients/view.py +++ b/tuttle/app/clients/view.py @@ -3,16 +3,22 @@ from flet import ( AlertDialog, Card, + ClipBehavior, Column, Container, Icon, + Icons, ListTile, + MainAxisAlignment, + CrossAxisAlignment, ResponsiveRow, Row, Text, + TextButton, Control, Alignment, Border, + BorderSide, Padding, ) @@ -30,91 +36,86 @@ def _initials(name: str) -> str: return "".join(p[0].upper() for p in parts[:2]) if parts else "?" -class ClientCard(Container): - """Flat bordered card for a client entity.""" +class ClientRow(Container): + """Single-line list row for a client — macOS native table style.""" def __init__( self, client: Client, - on_edit: Optional[Callable] = None, - on_delete: Optional[Callable] = None, + on_click=None, + on_edit=None, + on_delete=None, + is_selected=False, ): self.client = client - self.on_edit_clicked = on_edit - self.on_delete_clicked = on_delete - - initials = _initials(client.name) - avatar = Container( - width=36, - height=36, - bgcolor=colors.accent_muted, - border_radius=dimens.RADIUS_LG, - alignment=Alignment.CENTER, - content=Text( - initials, - size=fonts.BODY_1_SIZE, - color=colors.accent, - weight=fonts.BOLD_FONT, - ), - ) - editable = on_edit or on_delete - trailing = ( - views.TContextMenu( - on_click_delete=lambda e: self.on_delete_clicked(client), - on_click_edit=lambda e: self.on_edit_clicked(client), - ) - if editable - else views.Spacer(sm_space=True) + contact_name = ( + client.invoicing_contact.name if client.invoicing_contact else "—" + ) + company = ( + client.invoicing_contact.company + if client.invoicing_contact and client.invoicing_contact.company + else "—" ) - if client.invoicing_contact: - contact_info = client.invoicing_contact.print_address() - else: - contact_info = "Not specified" + _bg = colors.accent_muted if is_selected else colors.bg super().__init__( - expand=True, - bgcolor=colors.bg_surface, - border=Border.all(dimens.CARD_BORDER_WIDTH, colors.border), - border_radius=dimens.RADIUS_LG, - padding=Padding.all(dimens.SPACE_MD), + bgcolor=_bg, + border=Border(bottom=BorderSide(1, colors.border)), + padding=Padding.symmetric( + horizontal=dimens.SPACE_MD, vertical=dimens.SPACE_SM + ), + on_click=lambda e: on_click(client) if on_click else None, on_hover=self._on_hover, - content=Column( - spacing=dimens.SPACE_SM, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Row( + spacing=dimens.SPACE_MD, + vertical_alignment=utils.CENTER_ALIGNMENT, controls=[ - Row( - controls=[ - Row( - controls=[ - avatar, - views.TBodyText( - client.name, weight=fonts.BOLD_FONT - ), - ], - spacing=dimens.SPACE_SM, - vertical_alignment=utils.CENTER_ALIGNMENT, - ), - trailing, - ], - alignment=utils.SPACE_BETWEEN_ALIGNMENT, - vertical_alignment=utils.CENTER_ALIGNMENT, + Container( + expand=True, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + client.name or "", + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT if is_selected else None, + overflow="ellipsis", + max_lines=1, + ), ), - Container(height=1, bgcolor=colors.border_subtle), - views.TBodyText( - "Invoicing Contact", - color=colors.text_muted, - size=fonts.OVERLINE_SIZE, + Container( + width=200, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + contact_name, + size=fonts.BODY_2_SIZE, + color=colors.text_secondary, + overflow="ellipsis", + max_lines=1, + ), + ), + Container( + width=200, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + company, + size=fonts.BODY_2_SIZE, + color=colors.text_muted, + overflow="ellipsis", + max_lines=1, + ), ), - views.TBodyText(contact_info, size=fonts.BODY_2_SIZE), ], ), ) + self._is_selected = is_selected def _on_hover(self, e): - self.bgcolor = ( - colors.bg_surface_hovered if e.data == "true" else colors.bg_surface - ) + if self._is_selected: + return + self.bgcolor = colors.bg_surface_hovered if e.data == "true" else colors.bg self.update() @@ -131,7 +132,7 @@ def __init__( content=Container( content=Column( scroll=utils.AUTO_SCROLL, - controls=[ClientCard(client=client)], + controls=[ClientRow(client=client)], ), width=480, ), @@ -414,6 +415,319 @@ def build(self): self.controls = [self.dialog] +# ── Side panel ──────────────────────────────────────────────── + + +class ClientSidePanel(views.EntitySidePanel): + """Right-side panel for viewing and editing clients.""" + + def __init__( + self, + on_close, + on_save, + on_delete, + intent: ClientsIntent, + on_edit_requested=None, + ): + self.intent = intent + self._contacts_map: dict = {} + self._invoicing_contact: Optional[Contact] = None + self._address: Optional[Address] = None + super().__init__( + on_close=on_close, + on_save=on_save, + on_delete=on_delete, + on_edit_requested=on_edit_requested, + ) + + def _load_contacts(self): + self._contacts_map = self.intent.get_all_contacts_as_map() + + def _contact_item(self, cid): + return f"{cid}. {self._contacts_map[cid].name}" + + def _contact_options(self): + return [self._contact_item(cid) for cid in self._contacts_map] + + # -- Detail view ---------------------------------------------------------- + + def build_detail_content(self, entity: Client) -> list: + c = entity + contact = c.invoicing_contact + controls = [] + + # Client name as heading is already handled by panel title + + if contact: + name = ( + f"{contact.first_name or ''} {contact.last_name or ''}".strip() or "—" + ) + controls.append( + self._get_detail_field("Contact Name", name, Icons.PERSON_OUTLINE) + ) + if contact.company: + controls.append( + self._get_detail_field("Company", contact.company, Icons.BUSINESS) + ) + if contact.email: + controls.append( + self._get_detail_field("Email", contact.email, Icons.EMAIL_OUTLINED) + ) + controls.append(self._get_section_divider()) + + addr = contact.address + if addr and not addr.is_empty: + street = f"{addr.street or ''} {addr.number or ''}".strip() + city_line = f"{addr.postal_code or ''} {addr.city or ''}".strip() + country = addr.country or "" + addr_str = "\n".join(filter(None, [street, city_line, country])) + controls.append( + self._get_detail_field( + "Address", addr_str, Icons.LOCATION_ON_OUTLINED + ) + ) + else: + controls.append( + self._get_detail_field( + "Address", "Not specified", Icons.LOCATION_ON_OUTLINED + ) + ) + else: + controls.append(self._get_detail_field("Contact", "Not specified")) + + controls.append(self._get_section_divider()) + + # Actions + controls.append( + self._get_action_bar( + views.TPrimaryButton( + label="Edit", + on_click=lambda e: self._switch_to_edit(), + icon=Icons.EDIT_OUTLINED, + ), + TextButton( + content=Text("Delete", color=colors.danger, size=fonts.BODY_2_SIZE), + on_click=lambda e: self._on_delete_cb(entity) + if self._on_delete_cb + else None, + ), + ) + ) + return controls + + def build_compact_detail(self, entity: Client) -> list: + contact = entity.invoicing_contact + email = "\u2014" + addr_str = "\u2014" + if contact: + email = contact.email or "\u2014" + addr = contact.address + if addr and not addr.is_empty: + street = f"{addr.street or ''} {addr.number or ''}".strip() + city_line = f"{addr.postal_code or ''} {addr.city or ''}".strip() + country = addr.country or "" + addr_str = ", ".join(filter(None, [street, city_line, country])) + + return [ + ResponsiveRow( + controls=[ + self._compact_field("Email", email), + self._compact_field("Address", addr_str, col={"xs": 6}), + ], + ), + self._get_action_bar( + views.TPrimaryButton( + label="Edit", + on_click=lambda e: self._switch_to_edit(), + icon=Icons.EDIT_OUTLINED, + ), + TextButton( + content=Text("Delete", color=colors.danger, size=fonts.BODY_2_SIZE), + on_click=lambda e: self._on_delete_cb(entity) + if self._on_delete_cb + else None, + ), + ), + ] + + # -- Edit view ------------------------------------------------------------ + + def build_edit_content(self, entity: Optional[Client]) -> list: + self._load_contacts() + is_new = entity is None + + client = entity or Client() + self._invoicing_contact = ( + client.invoicing_contact if client.invoicing_contact else Contact() + ) + self._address = ( + self._invoicing_contact.address + if self._invoicing_contact.address + else Address() + ) + if not self._invoicing_contact.address: + self._invoicing_contact.address = self._address + + self._client_name_field = views.TTextField( + label="Client Name", + hint="Client's name", + initial_value=client.name or "", + ) + + # Contact selector + self._contacts_field = views.TDropDown( + label="Existing contact", + on_change=self._on_contact_selected, + items=self._contact_options(), + ) + if ( + self._invoicing_contact.id + and self._invoicing_contact.id in self._contacts_map + ): + self._contacts_field.update_value( + self._contact_item(self._invoicing_contact.id) + ) + + self._fname_field = views.TTextField( + label="First Name", + initial_value=self._invoicing_contact.first_name or "", + ) + self._lname_field = views.TTextField( + label="Last Name", + initial_value=self._invoicing_contact.last_name or "", + ) + self._company_field = views.TTextField( + label="Company", + initial_value=self._invoicing_contact.company or "", + ) + self._email_field = views.TTextField( + label="Email", + initial_value=self._invoicing_contact.email or "", + ) + self._street_field = views.TTextField( + label="Street", + initial_value=self._address.street or "", + ) + self._street_num_field = views.TTextField( + label="No.", + initial_value=self._address.number or "", + ) + self._postal_field = views.TTextField( + label="Postal Code", + initial_value=self._address.postal_code or "", + ) + self._city_field = views.TTextField( + label="City", + initial_value=self._address.city or "", + ) + self._country_field = views.TTextField( + label="Country", + initial_value=self._address.country or "", + ) + + save_label = "Create Client" if is_new else "Save Changes" + + # -- Compact multi-column layout -- + self._client_name_field.col = {"xs": 12, "sm": 6} + self._contacts_field.col = {"xs": 12, "sm": 6} + self._fname_field.col = {"xs": 6, "sm": 4} + self._lname_field.col = {"xs": 6, "sm": 4} + self._company_field.col = {"xs": 12, "sm": 4} + self._email_field.col = {"xs": 12, "sm": 6} + self._street_field.col = {"xs": 8, "sm": 5} + self._street_num_field.col = {"xs": 4, "sm": 1} + self._postal_field.col = {"xs": 4, "sm": 2} + self._city_field.col = {"xs": 4, "sm": 2} + self._country_field.col = {"xs": 4, "sm": 2} + + return [ + ResponsiveRow( + controls=[self._client_name_field, self._contacts_field], + spacing=dimens.SPACE_SM, + ), + ResponsiveRow( + controls=[self._fname_field, self._lname_field, self._company_field], + spacing=dimens.SPACE_SM, + ), + ResponsiveRow( + controls=[ + self._email_field, + self._street_field, + self._street_num_field, + self._postal_field, + self._city_field, + self._country_field, + ], + spacing=dimens.SPACE_SM, + ), + self._edit_action_bar( + save_label, + on_save=lambda e: self._validate_and_save(), + on_cancel=lambda e: self.close(), + ), + ] + + def _on_contact_selected(self, e): + sel = e.control.value + cid = int(sel.split(".")[0]) + if cid in self._contacts_map: + self._invoicing_contact = self._contacts_map[cid] + self._address = self._invoicing_contact.address or Address() + # Fill in the fields + self._fname_field.value = self._invoicing_contact.first_name or "" + self._lname_field.value = self._invoicing_contact.last_name or "" + self._company_field.value = self._invoicing_contact.company or "" + self._email_field.value = self._invoicing_contact.email or "" + self._street_field.value = self._address.street or "" + self._street_num_field.value = self._address.number or "" + self._postal_field.value = self._address.postal_code or "" + self._city_field.value = self._address.city or "" + self._country_field.value = self._address.country or "" + self.update() + + def _validate_and_save(self): + client_name = (self._client_name_field.value or "").strip() + if not client_name: + self._client_name_field.error = "Client name is required" + self.update() + return + + fname = (self._fname_field.value or "").strip() + lname = (self._lname_field.value or "").strip() + if not fname or not lname: + if not fname: + self._fname_field.error = "Required" + if not lname: + self._lname_field.error = "Required" + self.update() + return + + # Update address + self._address.street = (self._street_field.value or "").strip() + self._address.number = (self._street_num_field.value or "").strip() + self._address.postal_code = (self._postal_field.value or "").strip() + self._address.city = (self._city_field.value or "").strip() + self._address.country = (self._country_field.value or "").strip() + + if self._address.is_empty: + return # need at least some address + + # Update contact + self._invoicing_contact.first_name = fname + self._invoicing_contact.last_name = lname + self._invoicing_contact.company = (self._company_field.value or "").strip() + self._invoicing_contact.email = (self._email_field.value or "").strip() + self._invoicing_contact.address = self._address + + # Update client + client = self._entity or Client() + client.name = client_name + client.invoicing_contact = self._invoicing_contact + + if self._on_save_cb: + self._on_save_cb(client) + + class ClientsListView(views.CrudListView): """View for displaying a list of clients""" @@ -429,59 +743,55 @@ def get_sortable_fields(self): def __init__(self, params: TViewParams): self.intent = ClientsIntent() super().__init__(params=params) - self.contacts = {} - self.editor = None + + def get_side_panel(self): + return ClientSidePanel( + on_close=self._on_panel_closed, + on_save=self._on_save_client, + on_delete=self.on_delete_clicked, + intent=self.intent, + on_edit_requested=self._on_inline_edit_requested, + ) + + def get_column_headers(self): + return [ + ("Client", None), + ("Contact", 200), + ("Company", 200), + ] def make_card(self, client): - return ClientCard( + is_selected = self._selected_entity_id == ( + client.id if hasattr(client, "id") else None + ) + return ClientRow( client=client, - on_edit=self.on_edit_client_clicked, + on_click=lambda c: self.open_detail_panel(c), + on_edit=lambda c: self.open_edit_panel(c), on_delete=lambda c: self.on_delete_clicked(c), + is_selected=is_selected, ) def get_entity_description(self, client): return client.name def load_extra_data(self): - self.contacts = self.intent.get_all_contacts_as_map() + pass # contacts loaded by panel on demand + + def parent_intent_listener(self, intent: str, data=None): + if intent == res_utils.RELOAD_INTENT: + self.reload_all_data() + elif intent == res_utils.ADD_CLIENT_INTENT: + self.open_edit_panel(None) def open_add_editor(self, data=None): - if self.editor: - self.editor.close_dialog() - self.editor = ClientEditorPopUp( - self.dialog_controller, - on_submit=self._on_save_client, - contacts_map=self.contacts, - on_error=lambda error: self.show_snack(error, is_error=True), - ) - self.editor.open_dialog() - - def on_edit_client_clicked(self, client: Client): - if self.editor: - self.editor.close_dialog() - self.editor = ClientEditorPopUp( - self.dialog_controller, - on_submit=self._on_save_client, - contacts_map=self.contacts, - client=client, - on_error=lambda error: self.show_snack(error, is_error=True), - ) - self.editor.open_dialog() + self.open_edit_panel(None) def _on_save_client(self, client_to_save: Client): - self.loading_indicator.visible = True - self.update_self() result = self.intent.save_client(client_to_save) - is_error = not result.was_intent_successful - if not is_error: - self.items_to_display[result.data.id] = result.data - self.refresh_list() - msg = result.error_msg if is_error else "Client saved!" - self.show_snack(msg, is_error) - self.loading_indicator.visible = False - self.update_self() - - def will_unmount(self): - super().will_unmount() - if self.editor: - self.editor.dimiss_open_dialogs() + if result.was_intent_successful: + self.show_snack("Client saved!") + self._side_panel.close() + self.reload_all_data() + else: + self.show_snack(result.error_msg, is_error=True) diff --git a/tuttle/app/contacts/view.py b/tuttle/app/contacts/view.py index e0b7a1d..c59aceb 100644 --- a/tuttle/app/contacts/view.py +++ b/tuttle/app/contacts/view.py @@ -3,16 +3,22 @@ from flet import ( AlertDialog, Card, + ClipBehavior, Column, Container, Icon, + Icons, ListTile, + MainAxisAlignment, + CrossAxisAlignment, ResponsiveRow, Row, Text, + TextButton, Control, Alignment, Border, + BorderSide, Padding, ) @@ -31,105 +37,80 @@ def _initials(name: str) -> str: return "".join(p[0].upper() for p in parts[:2]) if parts else "?" -class ContactCard(Container): - """Flat, bordered card for a contact — VS Code panel style.""" +class ContactRow(Container): + """Single-line list row for a contact — macOS native table style.""" - def __init__(self, contact: Contact, on_edit_clicked, on_deleted_clicked): + def __init__( + self, + contact: Contact, + on_click, + on_edit_clicked, + on_deleted_clicked, + is_selected=False, + ): self.contact = contact - self.on_edit_clicked = on_edit_clicked - self.on_deleted_clicked = on_deleted_clicked - - initials = _initials(contact.name) - avatar = Container( - width=36, - height=36, - bgcolor=colors.accent_muted, - border_radius=dimens.RADIUS_LG, - alignment=Alignment.CENTER, - content=Text( - initials, - size=fonts.BODY_1_SIZE, - color=colors.accent, - weight=fonts.BOLD_FONT, - ), - ) - - header = Row( - controls=[ - avatar, - Column( - spacing=0, - controls=[ - views.TBodyText( - utils.truncate_str(contact.name, 30), weight=fonts.BOLD_FONT - ), - views.TBodyText( - utils.truncate_str(contact.company, 30), - color=colors.text_secondary, - size=fonts.BODY_2_SIZE, - ), - ], - ), - ], - spacing=dimens.SPACE_SM, - expand=True, - vertical_alignment=utils.CENTER_ALIGNMENT, - ) - context_menu = views.TContextMenu( - on_click_edit=lambda e: self.on_edit_clicked(contact), - on_click_delete=lambda e: self.on_deleted_clicked(contact), - ) + company = contact.company or "—" + email = contact.email or "—" - body_items = [] - if contact.email: - body_items.extend( - [ - views.TBodyText( - "Email", color=colors.text_muted, size=fonts.OVERLINE_SIZE - ), - views.TBodyText(contact.email, size=fonts.BODY_2_SIZE), - views.Spacer(sm_space=True), - ] - ) - address_str = ( - contact.print_address(address_only=True).strip() if contact.address else "" - ) - if address_str: - body_items.extend( - [ - views.TBodyText( - "Address", color=colors.text_muted, size=fonts.OVERLINE_SIZE - ), - views.TBodyText(address_str, size=fonts.BODY_2_SIZE), - ] - ) + _bg = colors.accent_muted if is_selected else colors.bg super().__init__( - expand=True, - bgcolor=colors.bg_surface, - border=Border.all(dimens.CARD_BORDER_WIDTH, colors.border), - border_radius=dimens.RADIUS_LG, - padding=Padding.all(dimens.SPACE_MD), + bgcolor=_bg, + border=Border(bottom=BorderSide(1, colors.border)), + padding=Padding.symmetric( + horizontal=dimens.SPACE_MD, vertical=dimens.SPACE_SM + ), + on_click=lambda e: on_click(contact), on_hover=self._on_hover, - content=Column( - spacing=dimens.SPACE_SM, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Row( + spacing=dimens.SPACE_MD, + vertical_alignment=utils.CENTER_ALIGNMENT, controls=[ - Row( - controls=[header, context_menu], - alignment=utils.SPACE_BETWEEN_ALIGNMENT, - vertical_alignment=utils.START_ALIGNMENT, + Container( + expand=True, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + contact.name or "", + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT if is_selected else None, + overflow="ellipsis", + max_lines=1, + ), + ), + Container( + width=200, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + company, + size=fonts.BODY_2_SIZE, + color=colors.text_secondary, + overflow="ellipsis", + max_lines=1, + ), + ), + Container( + width=220, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + email, + size=fonts.BODY_2_SIZE, + color=colors.text_muted, + overflow="ellipsis", + max_lines=1, + ), ), - Container(height=1, bgcolor=colors.border_subtle), - *body_items, ], ), ) + self._is_selected = is_selected def _on_hover(self, e): - self.bgcolor = ( - colors.bg_surface_hovered if e.data == "true" else colors.bg_surface - ) + if self._is_selected: + return + self.bgcolor = colors.bg_surface_hovered if e.data == "true" else colors.bg self.update() @@ -276,6 +257,225 @@ def on_submit_btn_clicked(self, e): self.on_submit_callback(self.contact) +# ── Side panel ──────────────────────────────────────────────── + + +class ContactSidePanel(views.EntitySidePanel): + """Right-side panel for viewing and editing contacts.""" + + def __init__( + self, + on_close, + on_save, + on_delete, + intent: ContactsIntent, + on_edit_requested=None, + ): + self.intent = intent + super().__init__( + on_close=on_close, + on_save=on_save, + on_delete=on_delete, + on_edit_requested=on_edit_requested, + ) + + # -- Detail view ---------------------------------------------------------- + + def build_detail_content(self, entity: Contact) -> list: + c = entity + controls = [] + + name = f"{c.first_name or ''} {c.last_name or ''}".strip() or "—" + controls.append(self._get_detail_field("Name", name, Icons.PERSON_OUTLINE)) + if c.company: + controls.append( + self._get_detail_field("Company", c.company, Icons.BUSINESS) + ) + if c.email: + controls.append( + self._get_detail_field("Email", c.email, Icons.EMAIL_OUTLINED) + ) + controls.append(self._get_section_divider()) + + addr = c.address + if addr and not addr.is_empty: + street = f"{addr.street or ''} {addr.number or ''}".strip() + city_line = f"{addr.postal_code or ''} {addr.city or ''}".strip() + country = addr.country or "" + addr_str = "\n".join(filter(None, [street, city_line, country])) + controls.append( + self._get_detail_field("Address", addr_str, Icons.LOCATION_ON_OUTLINED) + ) + else: + controls.append( + self._get_detail_field( + "Address", "Not specified", Icons.LOCATION_ON_OUTLINED + ) + ) + + controls.append(self._get_section_divider()) + + controls.append( + self._get_action_bar( + views.TPrimaryButton( + label="Edit", + on_click=lambda e: self._switch_to_edit(), + icon=Icons.EDIT_OUTLINED, + ), + TextButton( + content=Text("Delete", color=colors.danger, size=fonts.BODY_2_SIZE), + on_click=lambda e: self._on_delete_cb(entity) + if self._on_delete_cb + else None, + ), + ) + ) + return controls + + def build_compact_detail(self, entity: Contact) -> list: + c = entity + addr_str = "\u2014" + addr = c.address + if addr and not addr.is_empty: + street = f"{addr.street or ''} {addr.number or ''}".strip() + city_line = f"{addr.postal_code or ''} {addr.city or ''}".strip() + country = addr.country or "" + addr_str = ", ".join(filter(None, [street, city_line, country])) + + return [ + ResponsiveRow( + controls=[ + self._compact_field("Address", addr_str, col={"xs": 6}), + ], + ), + self._get_action_bar( + views.TPrimaryButton( + label="Edit", + on_click=lambda e: self._switch_to_edit(), + icon=Icons.EDIT_OUTLINED, + ), + TextButton( + content=Text("Delete", color=colors.danger, size=fonts.BODY_2_SIZE), + on_click=lambda e: self._on_delete_cb(entity) + if self._on_delete_cb + else None, + ), + ), + ] + + # -- Edit view ------------------------------------------------------------ + + def build_edit_content(self, entity: Optional[Contact]) -> list: + is_new = entity is None + contact = entity or Contact() + if not contact.address: + contact.address = Address() + + self._fname_field = views.TTextField( + label="First Name", + initial_value=contact.first_name or "", + ) + self._lname_field = views.TTextField( + label="Last Name", + initial_value=contact.last_name or "", + ) + self._company_field = views.TTextField( + label="Company", + initial_value=contact.company or "", + ) + self._email_field = views.TTextField( + label="Email", + initial_value=contact.email or "", + ) + self._street_field = views.TTextField( + label="Street", + initial_value=contact.address.street or "", + ) + self._street_num_field = views.TTextField( + label="No.", + initial_value=contact.address.number or "", + ) + self._postal_field = views.TTextField( + label="Postal Code", + initial_value=contact.address.postal_code or "", + ) + self._city_field = views.TTextField( + label="City", + initial_value=contact.address.city or "", + ) + self._country_field = views.TTextField( + label="Country", + initial_value=contact.address.country or "", + ) + + save_label = "Create Contact" if is_new else "Save Changes" + + # -- Compact multi-column layout -- + self._fname_field.col = {"xs": 6, "sm": 4} + self._lname_field.col = {"xs": 6, "sm": 4} + self._company_field.col = {"xs": 12, "sm": 4} + self._email_field.col = {"xs": 12, "sm": 6} + self._street_field.col = {"xs": 8, "sm": 5} + self._street_num_field.col = {"xs": 4, "sm": 1} + self._postal_field.col = {"xs": 4, "sm": 2} + self._city_field.col = {"xs": 4, "sm": 2} + self._country_field.col = {"xs": 4, "sm": 2} + + return [ + ResponsiveRow( + controls=[self._fname_field, self._lname_field, self._company_field], + spacing=dimens.SPACE_SM, + ), + ResponsiveRow( + controls=[ + self._email_field, + self._street_field, + self._street_num_field, + self._postal_field, + self._city_field, + self._country_field, + ], + spacing=dimens.SPACE_SM, + ), + self._edit_action_bar( + save_label, + on_save=lambda e: self._validate_and_save(), + on_cancel=lambda e: self.close(), + ), + ] + + def _validate_and_save(self): + fname = (self._fname_field.value or "").strip() + lname = (self._lname_field.value or "").strip() + if not fname: + self._fname_field.error = "Required" + self.update() + return + if not lname: + self._lname_field.error = "Required" + self.update() + return + + contact = self._entity or Contact() + if not contact.address: + contact.address = Address() + contact.first_name = fname + contact.last_name = lname + contact.company = (self._company_field.value or "").strip() + contact.email = (self._email_field.value or "").strip() + contact.address.street = (self._street_field.value or "").strip() + contact.address.number = (self._street_num_field.value or "").strip() + contact.address.postal_code = (self._postal_field.value or "").strip() + contact.address.city = (self._city_field.value or "").strip() + contact.address.country = (self._country_field.value or "").strip() + + if contact.address.is_empty: + return # need some address info + + if self._on_save_cb: + self._on_save_cb(contact) + + class ContactsListView(views.CrudListView): """The view for the contacts list page""" @@ -293,52 +493,55 @@ def get_sortable_fields(self): def __init__(self, params: TViewParams): self.intent = ContactsIntent() super().__init__(params) - self.editor = None + + def get_side_panel(self): + return ContactSidePanel( + on_close=self._on_panel_closed, + on_save=self._on_save_contact, + on_delete=self.on_delete_clicked, + intent=self.intent, + on_edit_requested=self._on_inline_edit_requested, + ) + + def get_column_headers(self): + return [ + ("Name", None), + ("Company", 200), + ("Email", 220), + ] def make_card(self, contact): - return ContactCard( + is_selected = self._selected_entity_id == ( + contact.id if hasattr(contact, "id") else None + ) + return ContactRow( contact=contact, - on_edit_clicked=self.on_edit_contact_clicked, + on_click=lambda c: self.open_detail_panel(c), + on_edit_clicked=lambda c: self.open_edit_panel(c), on_deleted_clicked=lambda c: self.on_delete_clicked(c), + is_selected=is_selected, ) def get_entity_description(self, contact): return f"{contact.first_name} {contact.last_name}" def open_add_editor(self, data=None): - if self.editor: - self.editor.close_dialog() - self.editor = ContactEditorPopUp( - dialog_controller=self.dialog_controller, - on_submit=self._on_save_contact, - on_error=lambda error: self.show_snack(error, is_error=True), - ) - self.editor.open_dialog() + self.open_edit_panel(None) - def on_edit_contact_clicked(self, contact: Contact): - if self.editor: - self.editor.close_dialog() - self.editor = ContactEditorPopUp( - dialog_controller=self.dialog_controller, - contact=contact, - on_submit=self._on_save_contact, - on_error=lambda error: self.show_snack(error, is_error=True), - ) - self.editor.open_dialog() + def parent_intent_listener(self, intent: str, data=None): + if intent == res_utils.RELOAD_INTENT: + self.reload_all_data() + elif intent == res_utils.ADD_CONTACT_INTENT: + self.open_edit_panel(None) def _on_save_contact(self, contact): - self.loading_indicator.visible = True - self.update_self() result = self.intent.save_contact(contact) - is_error = not result.was_intent_successful - if not is_error: - saved = result.data - self.items_to_display[saved.id] = saved - self.refresh_list() - msg = result.error_msg if is_error else "Contact saved!" - self.show_snack(msg, is_error) - self.loading_indicator.visible = False - self.update_self() + if result.was_intent_successful: + self.show_snack("Contact saved!") + self._side_panel.close() + self.reload_all_data() + else: + self.show_snack(result.error_msg, is_error=True) def on_delete_confirmed(self, contact_id): """Uses delete_contact for referential integrity check.""" @@ -353,8 +556,3 @@ def on_delete_confirmed(self, contact_id): self.refresh_list() self.loading_indicator.visible = False self.update_self() - - def will_unmount(self): - super().will_unmount() - if self.editor: - self.editor.dimiss_open_dialogs() diff --git a/tuttle/app/contracts/view.py b/tuttle/app/contracts/view.py index 198dcc7..526ba03 100644 --- a/tuttle/app/contracts/view.py +++ b/tuttle/app/contracts/view.py @@ -5,11 +5,14 @@ from flet import ( Card, + ClipBehavior, Column, Container, Icon, IconButton, ListTile, + MainAxisAlignment, + CrossAxisAlignment, ResponsiveRow, Row, Text, @@ -17,11 +20,11 @@ Control, Alignment, Border, + BorderSide, Icons, Padding, ) -from ..clients.view import ClientEditorPopUp, ClientViewPopUp from ..contracts.intent import ContractsIntent from ..core import utils, views from ..core.abstractions import DialogHandler, TView, TViewParams @@ -39,519 +42,547 @@ def _contract_initials(title: str) -> str: return "".join(p[0].upper() for p in parts[:2]) if parts else "?" -class ContractCard(Container): - """Flat, bordered card for a contract — VS Code panel style.""" +class ContractRow(Container): + """Single-line list row for a contract — macOS native table style.""" def __init__( - self, contract: Contract, on_click_view, on_click_edit, on_click_delete + self, + contract: Contract, + on_click, + on_click_edit, + on_click_delete, + is_selected=False, ): self.contract = contract - self.on_click_view = on_click_view - self.on_click_edit = on_click_edit - self.on_click_delete = on_click_delete - - client_name = contract.client.name if contract.client else "Unknown" - initials = _contract_initials(contract.title) - avatar = Container( - width=36, - height=36, - bgcolor=colors.accent_muted, - border_radius=dimens.RADIUS_LG, - alignment=Alignment.CENTER, - content=Text( - initials, - size=fonts.BODY_1_SIZE, - color=colors.accent, - weight=fonts.BOLD_FONT, - ), - ) - header = Row( - controls=[ - avatar, - Column( - spacing=0, - controls=[ - views.TBodyText( - utils.truncate_str(contract.title, 30), - weight=fonts.BOLD_FONT, - ), - views.TBodyText( - client_name, - color=colors.text_secondary, - size=fonts.BODY_2_SIZE, - ), - ], - ), - ], - spacing=dimens.SPACE_SM, - expand=True, - vertical_alignment=utils.CENTER_ALIGNMENT, - ) + client_name = contract.client.name if contract.client else "—" + + _status = contract.get_status() + _dot_color = { + "Active": colors.status_active, + "Upcoming": colors.status_upcoming, + "Completed": colors.status_completed, + }.get(_status, colors.text_muted) - context_menu = views.TContextMenu( - on_click_view=lambda e: self.on_click_view(contract.id), - on_click_edit=lambda e: self.on_click_edit(contract.id), - on_click_delete=lambda e: self.on_click_delete(contract.id), + status_dot = Container( + width=8, + height=8, + bgcolor=_dot_color, + border_radius=dimens.RADIUS_PILL, ) - def _info_row(label, value): - return Column( - spacing=2, - controls=[ - views.TBodyText( - label, color=colors.text_muted, size=fonts.OVERLINE_SIZE - ), - views.TBodyText(value, size=fonts.BODY_2_SIZE), - ], - ) + rate_str = ( + f"{contract.rate} {contract.currency}/{contract.unit}" + if contract.rate + else "—" + ) - body_items = [ - _info_row("Rate", f"{contract.rate} {contract.currency} / {contract.unit}"), - _info_row("Billing Cycle", f"{contract.billing_cycle}"), - _info_row("Volume", f"{contract.volume} {contract.unit}s"), - ] + _bg = colors.accent_muted if is_selected else colors.bg super().__init__( - expand=True, - bgcolor=colors.bg_surface, - border=Border.all(dimens.CARD_BORDER_WIDTH, colors.border), - border_radius=dimens.RADIUS_LG, - padding=Padding.all(dimens.SPACE_MD), + bgcolor=_bg, + border=Border(bottom=BorderSide(1, colors.border)), + padding=Padding.symmetric( + horizontal=dimens.SPACE_MD, vertical=dimens.SPACE_SM + ), + on_click=lambda e: on_click(contract.id), on_hover=self._on_hover, - on_click=lambda e: self.on_click_view(contract.id), - content=Column( - spacing=dimens.SPACE_SM, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Row( + spacing=dimens.SPACE_MD, + vertical_alignment=utils.CENTER_ALIGNMENT, controls=[ - Row( - controls=[header, context_menu], - alignment=utils.SPACE_BETWEEN_ALIGNMENT, - vertical_alignment=utils.START_ALIGNMENT, + Container( + expand=True, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Row( + spacing=dimens.SPACE_XS, + vertical_alignment=utils.CENTER_ALIGNMENT, + controls=[ + status_dot, + Text( + contract.title or "", + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT if is_selected else None, + overflow="ellipsis", + max_lines=1, + expand=True, + ), + ], + ), + ), + Container( + width=180, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + client_name, + size=fonts.BODY_2_SIZE, + color=colors.text_secondary, + overflow="ellipsis", + max_lines=1, + ), + ), + Container( + width=160, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + rate_str, + size=fonts.BODY_2_SIZE, + color=colors.text_secondary, + overflow="ellipsis", + max_lines=1, + ), + ), + Container( + width=120, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + str(contract.billing_cycle) + if contract.billing_cycle + else "—", + size=fonts.BODY_2_SIZE, + color=colors.text_muted, + overflow="ellipsis", + max_lines=1, + ), ), - Container(height=1, bgcolor=colors.border_subtle), - *body_items, ], ), ) + self._is_selected = is_selected def _on_hover(self, e): - self.bgcolor = ( - colors.bg_surface_hovered if e.data == "true" else colors.bg_surface - ) + if self._is_selected: + return + self.bgcolor = colors.bg_surface_hovered if e.data == "true" else colors.bg self.update() -class ContractEditorScreen(TView, Container): - """Used to edit or create a contract""" +# ── Side panel ──────────────────────────────────────────────── - def __init__( - self, params: TViewParams, contract_id_if_editing: Optional[str] = False - ): - super().__init__(params=params) - self.horizontal_alignment_in_parent = utils.CENTER_ALIGNMENT - self.intent = ContractsIntent() - self.contract_id_if_editing: Optional[str] = contract_id_if_editing - self.old_contract_if_editing: Optional[Contract] = None - self.loading_indicator = views.TProgressBar() - self.new_client_pop_up: Optional[DialogHandler] = None - - # info of contract being edited / created - self.clients_map = {} - self.contacts_map = {} - self.available_currencies = [] - self.client = None - - def clear_ui_field_errors(self, e): - """Clears all the errors in the ui form fields""" - fields = [ - self.title_ui_field, - self.rate_ui_field, - self.volume_ui_field, - self.term_of_payment_ui_field, - self.unit_PW_ui_field, - self.vat_rate_ui_field, - ] - for field in fields: - if field.error: - field.error = None - self.currency_ui_field.update_error_txt() - self.update_self() - - def toggle_progress(self, is_on_going_action: bool): - """Hides or shows the progress bar and enables or disables the submit btn""" - self.loading_indicator.visible = is_on_going_action - self.submit_btn.disabled = is_on_going_action - - def did_mount(self): - """Called when the view is mounted""" - self.mounted = True - self.toggle_progress(is_on_going_action=True) - self.load_clients() - self.fetch_and_set_contacts() - self.load_currencies() - # contract_for_update should be loaded last - self.load_contract_for_update() - self.toggle_progress(is_on_going_action=False) - self.update_self() - - def load_contract_for_update(self): - """Loads the contract for update if it is an update operation i.e self.contract_id_if_editing is not None""" - if not self.contract_id_if_editing: - return # a new contract is being created - result = self.intent.get_by_id(self.contract_id_if_editing) - if not result.was_intent_successful or not result.data: - self.show_snack(result.error_msg, is_error=True) - self.old_contract_if_editing = result.data - self.display_contract_info() - def load_currencies(self): - """Loads the available currencies into a dropdown""" - self.available_currencies = [ - abbreviation for (name, abbreviation, symbol) in utils.get_currencies() - ] - self.currency_ui_field.update_dropdown_items(self.available_currencies) - result = self.intent.get_preferred_currency_intent(self.client_storage) - if result.was_intent_successful: - preferred_currency = result.data - self.currency_ui_field.update_value(preferred_currency) - - def load_clients(self): - """Loads the clients into a dropdown""" - self.clients_map = self.intent.get_all_clients_as_map() - self.clients_ui_field.update_error_txt( - "Please create a new client" if len(self.clients_map) == 0 else "" - ) - self.clients_ui_field.update_dropdown_items(self.get_clients_names_as_list()) - - def fetch_and_set_contacts(self): - """fetches the contacts and sets them in the contacts map""" - self.contacts_map = self.intent.get_all_contacts_as_map() - - def get_clients_names_as_list(self): - """transforms a map of id-client_title to a list for dropdown options""" - client_names_list = [] - for key in self.clients_map: - client_names_list.append(self.get_client_dropdown_item(key)) - return client_names_list - - def get_client_dropdown_item(self, client_id): - """returns a string for the client's dropdown item""" - if client_id not in self.clients_map: - return "" - # prefix client name with a key {client_id} - return f"{client_id}. {self.clients_map[client_id].name}" - - def on_client_selected(self, e): - # parse selected value to extract id - selected = e.control.value - _id = "" - for c in selected: - if c == ".": - break - _id = _id + c - - # clear the error text if any - self.clients_ui_field.update_error_txt() - self.update_self() - if int(_id) in self.clients_map: - # set the client - self.client = self.clients_map[int(_id)] - - def on_add_client_clicked(self, e): - """Called when the add client button is clicked""" - if self.new_client_pop_up: - self.new_client_pop_up.close_dialog() - # open the client editor pop up - self.new_client_pop_up = ClientEditorPopUp( - dialog_controller=self.dialog_controller, - on_submit=self.on_client_set_from_pop_up, - contacts_map=self.contacts_map, - on_error=lambda error: self.show_snack( - error, - is_error=True, - ), - ) - self.new_client_pop_up.open_dialog() - - def on_client_set_from_pop_up(self, client): - """Called when the client is set from the client editor pop up""" - if client: - result: IntentResult = self.intent.save_client(client) - if result.was_intent_successful: - self.client: Client = result.data - self.clients_map[self.client.id] = self.client - - self.clients_ui_field.update_dropdown_items( - self.get_clients_names_as_list() - ) +class ContractSidePanel(views.EntitySidePanel): + """Right-side panel for viewing and editing contracts.""" - item = self.get_client_dropdown_item(self.client.id) - self.clients_ui_field.update_value(item) - self.clients_ui_field.update_error_txt() - else: - self.show_snack(result.error_msg, True) - self.update_self() - - def display_contract_info(self): - """initialize form fields with data from old contract""" - self.title_ui_field.value = self.old_contract_if_editing.title - signature_date = self.old_contract_if_editing.signature_date - self.signature_date_ui_field.set_date(signature_date) - start_date = self.old_contract_if_editing.start_date - self.start_date_ui_field.set_date(start_date) - end_date = self.old_contract_if_editing.end_date - self.end_date_ui_field.set_date(end_date) - self.client = self.old_contract_if_editing.client - if self.client: - self.clients_ui_field.update_value( - self.get_client_dropdown_item(self.client.id) - ) - self.rate_ui_field.value = self.old_contract_if_editing.rate - self.currency_ui_field.update_value(self.old_contract_if_editing.currency) - self.vat_rate_ui_field.value = self.old_contract_if_editing.VAT_rate - if self.old_contract_if_editing.unit: - self.time_unit_field.update_value(self.old_contract_if_editing.unit.name) - self.unit_PW_ui_field.value = self.old_contract_if_editing.units_per_workday - self.volume_ui_field.value = self.old_contract_if_editing.volume - self.term_of_payment_ui_field.value = ( - self.old_contract_if_editing.term_of_payment + def __init__( + self, + on_close, + on_save, + on_delete, + intent: ContractsIntent, + client_storage=None, + on_edit_requested=None, + ): + self.intent = intent + self._clients_map: dict = {} + self._contacts_map: dict = {} + self._client: Optional[Client] = None + self._client_storage = client_storage + super().__init__( + on_close=on_close, + on_save=on_save, + on_delete=on_delete, + on_edit_requested=on_edit_requested, ) - if self.old_contract_if_editing.billing_cycle: - self.billing_cycle_ui_field.update_value( - self.old_contract_if_editing.billing_cycle.name - ) - self.form_title_ui_field.value = "Edit Contract" - self.submit_btn.text = "Save changes" - - def on_save(self, e): - """Called when the edit / save button is clicked""" - # get data from form fields - title = self.title_ui_field.value - rate = self.rate_ui_field.value - vat_rate = self.vat_rate_ui_field.value - unit_pw = self.unit_PW_ui_field.value - volume = self.volume_ui_field.value - term_of_payment = self.term_of_payment_ui_field.value - currency = self.currency_ui_field.value - time_unit_str = self.time_unit_field.value - try: - time_unit = TimeUnit[time_unit_str] - except KeyError: - time_unit = None - billing_cycle_str = self.billing_cycle_ui_field.value - try: - billing_cycle = Cycle[billing_cycle_str] - except KeyError: - billing_cycle = None + def _load_data(self): + self._clients_map = self.intent.get_all_clients_as_map() + self._contacts_map = self.intent.get_all_contacts_as_map() + self._currencies = [abbr for (_, abbr, _) in utils.get_currencies()] - # check for missing fields - if not title: - self.title_ui_field.error = "Contract title is required" - self.update_self() - return # error occurred, stop here + def _client_item(self, cid): + return f"{cid}. {self._clients_map[cid].name}" - if not currency: - self.currency_ui_field.update_error_txt("Please specify the currency") - self.update_self() - return + def _client_options(self): + return [self._client_item(cid) for cid in self._clients_map] - if not rate: - self.rate_ui_field.error = "Rate of enumeration is required" - self.update_self() - return - - if not time_unit: - self.time_unit_field.update_error_txt("Unit of time tracked is required") - self.update_self() - return - - if not unit_pw: - self.unit_PW_ui_field.error = "Units per workday is required" - self.update_self() - return + # -- Detail view ---------------------------------------------------------- - if self.client is None: - self.clients_ui_field.update_error_txt("Please select a client") - self.update_self() - return # error occurred, stop here - - if not billing_cycle: - self.billing_cycle_ui_field.update_error_txt("Billing cycle is required") - self.update_self() - return + def build_detail_content(self, entity: Contract) -> list: + c = entity + _status = c.get_status(default="") + _status_color = { + "Active": colors.status_active, + "Upcoming": colors.status_upcoming, + "Completed": colors.status_completed, + }.get(_status, colors.text_muted) - signatureDate = self.signature_date_ui_field.get_date() - if signatureDate is None: - self.show_snack("Please specify the signature date", True) - return # error occurred, stop here + controls = [] + if _status: + controls.append( + Container( + border_radius=dimens.RADIUS_PILL, + bgcolor=_status_color, + padding=Padding.symmetric( + horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XXS + ), + content=Text( + _status, + size=fonts.CAPTION_SIZE, + color=colors.text_inverse, + weight=fonts.BOLD_FONT, + ), + ) + ) - startDate = self.start_date_ui_field.get_date() - if startDate is None: - self.show_snack("Please specify the start date", True) - return # error occurred, stop here + client_name = c.client.name if c.client else "Not specified" + controls.append( + self._get_detail_field("Client", client_name, Icons.PERSON_OUTLINE) + ) + controls.append(self._get_section_divider()) + + # Financial details + rate_str = f"{c.rate} {c.currency}" if c.rate else "—" + unit_str = c.unit.value if c.unit else "" + controls.append(self._get_detail_field("Rate", f"{rate_str} / {unit_str}")) + vat_str = f"{float(c.VAT_rate)*100:.0f}%" if c.VAT_rate is not None else "—" + controls.append(self._get_detail_field("VAT Rate", vat_str)) + controls.append( + self._get_detail_field( + "Billing Cycle", str(c.billing_cycle) if c.billing_cycle else "—" + ) + ) + vol_str = f"{c.volume} {unit_str}" if c.volume is not None else "—" + controls.append(self._get_detail_field("Volume", vol_str)) + upw_str = f"{c.units_per_workday} {unit_str}" if c.units_per_workday else "—" + controls.append(self._get_detail_field("Units / Workday", upw_str)) + top_str = f"{c.term_of_payment} days" if c.term_of_payment else "—" + controls.append(self._get_detail_field("Term of Payment", top_str)) + controls.append(self._get_section_divider()) + + # Dates + sig = c.signature_date.strftime("%d %b %Y") if c.signature_date else "—" + start = c.start_date.strftime("%d %b %Y") if c.start_date else "—" + end = c.end_date.strftime("%d %b %Y") if c.end_date else "—" + controls.append(self._get_detail_field("Signed", sig, Icons.DRAW)) + controls.append( + self._get_detail_field("Duration", f"{start} → {end}", Icons.DATE_RANGE) + ) + controls.append(self._get_section_divider()) + + # Actions + controls.append( + self._get_action_bar( + views.TPrimaryButton( + label="Edit", + on_click=lambda e: self._switch_to_edit(), + icon=Icons.EDIT_OUTLINED, + ), + TextButton( + content=Text("Delete", color=colors.danger, size=fonts.BODY_2_SIZE), + on_click=lambda e: self._on_delete_cb(entity) + if self._on_delete_cb + else None, + ), + ) + ) + return controls - endDate = self.end_date_ui_field.get_date() - if endDate is None: - self.show_snack("Please specify the end date", True) - return # error occurred, stop here + def build_compact_detail(self, entity: Contract) -> list: + c = entity + _status = c.get_status(default="") + _status_color = { + "Active": colors.status_active, + "Upcoming": colors.status_upcoming, + "Completed": colors.status_completed, + }.get(_status, colors.text_muted) + + unit_str = c.unit.value if c.unit else "" + vat_str = ( + f"{float(c.VAT_rate)*100:.0f}%" if c.VAT_rate is not None else "\u2014" + ) + vol_str = f"{c.volume} {unit_str}" if c.volume is not None else "\u2014" + upw_str = ( + f"{c.units_per_workday} {unit_str}" if c.units_per_workday else "\u2014" + ) + top_str = f"{c.term_of_payment} days" if c.term_of_payment else "\u2014" + sig = c.signature_date.strftime("%d %b %Y") if c.signature_date else "\u2014" + start = c.start_date.strftime("%d %b %Y") if c.start_date else "\u2014" + end = c.end_date.strftime("%d %b %Y") if c.end_date else "\u2014" - if endDate < startDate: - self.show_snack( - "The end date of the contract cannot be before the start date", True + top_row = [] + if _status: + top_row.append( + Container( + border_radius=dimens.RADIUS_PILL, + bgcolor=_status_color, + padding=Padding.symmetric( + horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XXS + ), + content=Text( + _status, + size=fonts.CAPTION_SIZE, + color=colors.text_inverse, + weight=fonts.BOLD_FONT, + ), + ) ) - return # error occurred, stop here - - vat_rate = self.vat_rate_ui_field.value - if not vat_rate: - vat_rate = CONTRACT_DEFAULT_VAT_RATE - self.toggle_progress(is_on_going_action=True) + return [ + Row(spacing=dimens.SPACE_SM, controls=top_row) + if top_row + else views.Spacer(xs_space=True), + ResponsiveRow( + controls=[ + self._compact_field("VAT", vat_str), + self._compact_field("Volume", vol_str), + self._compact_field("Units/Workday", upw_str), + self._compact_field("Payment Term", top_str), + ], + ), + ResponsiveRow( + controls=[ + self._compact_field("Signed", sig), + self._compact_field( + "Duration", f"{start} \u2192 {end}", col={"xs": 6} + ), + ], + ), + self._get_action_bar( + views.TPrimaryButton( + label="Edit", + on_click=lambda e: self._switch_to_edit(), + icon=Icons.EDIT_OUTLINED, + ), + TextButton( + content=Text("Delete", color=colors.danger, size=fonts.BODY_2_SIZE), + on_click=lambda e: self._on_delete_cb(entity) + if self._on_delete_cb + else None, + ), + ), + ] - contract = self.old_contract_if_editing or Contract() - contract.title = title - contract.signature_date = signatureDate - contract.start_date = startDate - contract.end_date = endDate - contract.client = self.client - contract.rate = rate - contract.currency = currency - contract.VAT_rate = vat_rate - contract.unit = time_unit - contract.units_per_workday = unit_pw - contract.volume = volume - contract.term_of_payment = term_of_payment - contract.billing_cycle = billing_cycle + # -- Edit view ------------------------------------------------------------ - result: IntentResult = self.intent.save_contract(contract) - success_msg = ( - "Changes saved" - if self.contract_id_if_editing - else "New contract created successfully" - ) - msg = success_msg if result.was_intent_successful else result.error_msg - isError = not result.was_intent_successful - self.toggle_progress(is_on_going_action=False) - self.show_snack(msg, isError) - if not isError: - # re route back - self.navigate_back() + def build_edit_content(self, entity: Optional[Contract]) -> list: + self._load_data() + is_new = entity is None - def build(self): - """Build the UI""" - self.title_ui_field = views.TTextField( + self._title_field = views.TTextField( label="Title", - hint="Short description of the contract.", - on_focus=self.clear_ui_field_errors, + hint="Short description", + initial_value=entity.title if entity else "", ) - self.rate_ui_field = views.TTextField( + self._rate_field = views.TTextField( label="Rate", - hint="Rate of remuneration", - on_focus=self.clear_ui_field_errors, + hint="Rate", + initial_value=str(entity.rate) if entity and entity.rate else "", keyboard_type=utils.KEYBOARD_NUMBER, ) - self.currency_ui_field = views.TDropDown( + + # Currency dropdown + preferred_currency = None + if self._client_storage: + r = self.intent.get_preferred_currency_intent(self._client_storage) + if r.was_intent_successful: + preferred_currency = r.data + cur_value = entity.currency if entity else preferred_currency + self._currency_field = views.TDropDown( label="Currency", - hint="Payment currency", - items=self.available_currencies, + items=self._currencies, ) - self.vat_rate_ui_field = views.TTextField( - label="VAT rate", - hint=f"VAT rate applied to the contractual rate. default is {CONTRACT_DEFAULT_VAT_RATE}", - on_focus=self.clear_ui_field_errors, + if cur_value: + self._currency_field.update_value(cur_value) + + self._vat_field = views.TTextField( + label="VAT Rate", + hint=f"Default: {CONTRACT_DEFAULT_VAT_RATE}", + initial_value=str(entity.VAT_rate) + if entity and entity.VAT_rate is not None + else "", keyboard_type=utils.KEYBOARD_NUMBER, ) - self.unit_PW_ui_field = views.TTextField( - label="Units per workday", - hint="How many units (e.g. hours) constitute a whole work day?", - on_focus=self.clear_ui_field_errors, + self._unit_pw_field = views.TTextField( + label="Units / workday", + hint="e.g. 8", + initial_value=str(entity.units_per_workday) + if entity and entity.units_per_workday + else "", keyboard_type=utils.KEYBOARD_NUMBER, ) - self.volume_ui_field = views.TTextField( - label="Volume (optional)", - hint="Number of time units agreed on", - on_focus=self.clear_ui_field_errors, + self._volume_field = views.TTextField( + label="Volume", + hint="Total units", + initial_value=str(entity.volume) + if entity and entity.volume is not None + else "", keyboard_type=utils.KEYBOARD_NUMBER, ) - self.term_of_payment_ui_field = views.TTextField( - label="Term of payment (optional)", - hint="How many days after receipt of invoice this invoice is due.", - on_focus=self.clear_ui_field_errors, + self._top_field = views.TTextField( + label="Payment term", + hint="Days", + initial_value=str(entity.term_of_payment) + if entity and entity.term_of_payment + else "", keyboard_type=utils.KEYBOARD_NUMBER, ) - self.clients_ui_field = views.TDropDown( - label="Client", - on_change=self.on_client_selected, - items=self.get_clients_names_as_list(), - ) - self.time_unit_field = views.TDropDown( - label="Unit of time tracked.", + + # Time unit + self._time_unit_field = views.TDropDown( + label="Time unit", items=[str(t) for t in TimeUnit], ) - self.billing_cycle_ui_field = views.TDropDown( - label="Billing Cycle", + if entity and entity.unit: + self._time_unit_field.update_value(entity.unit.name) + + # Billing cycle + self._billing_field = views.TDropDown( + label="Billing cycle", items=[str(c) for c in Cycle], ) - self.signature_date_ui_field = views.DateSelector(label="Signed on") - self.start_date_ui_field = views.DateSelector(label="Valid from") - self.end_date_ui_field = views.DateSelector(label="Valid until") - self.submit_btn = views.TPrimaryButton( - label="Create Contract", on_click=self.on_save - ) - self.form_title_ui_field = views.THeading( - title="New Contract", - ) - self.content = views.TFullScreenFormContainer( - form_controls=[ - Row( - controls=[ - views.TBackButton(on_click=self.navigate_back), - self.form_title_ui_field, - ] - ), - self.loading_indicator, - views.Spacer(md_space=True), - self.title_ui_field, - views.Spacer(sm_space=True), - self.currency_ui_field, - self.rate_ui_field, - self.term_of_payment_ui_field, - self.time_unit_field, - self.unit_PW_ui_field, - self.vat_rate_ui_field, - self.volume_ui_field, - views.Spacer(sm_space=True), - Row( - alignment=utils.SPACE_BETWEEN_ALIGNMENT, - vertical_alignment=utils.CENTER_ALIGNMENT, - spacing=dimens.SPACE_STD, - controls=[ - self.clients_ui_field, - IconButton( - icon=Icons.ADD_CIRCLE_OUTLINE, - on_click=self.on_add_client_clicked, - icon_size=dimens.ICON_SIZE, - ), - ], - ), - views.Spacer(sm_space=True), - self.billing_cycle_ui_field, - views.Spacer(sm_space=True), - self.signature_date_ui_field, - views.Spacer(sm_space=True), - self.start_date_ui_field, - views.Spacer(md_space=True), - self.end_date_ui_field, - views.Spacer(md_space=True), - self.submit_btn, - ], + if entity and entity.billing_cycle: + self._billing_field.update_value(entity.billing_cycle.name) + + # Client + self._client = entity.client if entity else None + self._clients_field = views.TDropDown( + label="Client", + on_change=self._on_client_selected, + items=self._client_options(), ) + if self._client and self._client.id in self._clients_map: + self._clients_field.update_value(self._client_item(self._client.id)) + + # Dates + self._sig_date_field = views.DateSelector(label="Signed on") + self._start_date_field = views.DateSelector(label="Valid from") + self._end_date_field = views.DateSelector(label="Valid until") + if entity: + if entity.signature_date: + self._sig_date_field.set_date(entity.signature_date) + if entity.start_date: + self._start_date_field.set_date(entity.start_date) + if entity.end_date: + self._end_date_field.set_date(entity.end_date) + + save_label = "Create Contract" if is_new else "Save Changes" + + # -- Compact multi-column layout -- + self._title_field.col = {"xs": 12, "sm": 6} + self._clients_field.col = {"xs": 12, "sm": 6} + self._rate_field.col = {"xs": 6, "sm": 3} + self._currency_field.col = {"xs": 6, "sm": 3} + self._time_unit_field.col = {"xs": 6, "sm": 3} + self._billing_field.col = {"xs": 6, "sm": 3} + self._vat_field.col = {"xs": 6, "sm": 3} + self._unit_pw_field.col = {"xs": 6, "sm": 3} + self._volume_field.col = {"xs": 6, "sm": 3} + self._top_field.col = {"xs": 6, "sm": 3} + self._sig_date_field.col = {"xs": 12, "sm": 4} + self._start_date_field.col = {"xs": 6, "sm": 4} + self._end_date_field.col = {"xs": 6, "sm": 4} - def will_unmount(self): - """Called when the view is about to be unmounted.""" - self.mounted = True - if self.new_client_pop_up: - self.new_client_pop_up.dimiss_open_dialogs() + return [ + ResponsiveRow( + controls=[self._title_field, self._clients_field], + spacing=dimens.SPACE_SM, + ), + ResponsiveRow( + controls=[ + self._rate_field, + self._currency_field, + self._time_unit_field, + self._billing_field, + ], + spacing=dimens.SPACE_SM, + ), + ResponsiveRow( + controls=[ + self._vat_field, + self._unit_pw_field, + self._volume_field, + self._top_field, + ], + spacing=dimens.SPACE_SM, + ), + ResponsiveRow( + controls=[ + self._sig_date_field, + self._start_date_field, + self._end_date_field, + ], + spacing=dimens.SPACE_SM, + ), + self._edit_action_bar( + save_label, + on_save=lambda e: self._validate_and_save(), + on_cancel=lambda e: self.close(), + ), + ] + + def _on_client_selected(self, e): + sel = e.control.value + cid = int(sel.split(".")[0]) + if cid in self._clients_map: + self._client = self._clients_map[cid] + + def _validate_and_save(self): + title = self._title_field.value + if not title: + self._title_field.error = "Title is required" + self.update() + return + currency = self._currency_field.value + if not currency: + self._currency_field.update_error_txt("Required") + self.update() + return + rate = self._rate_field.value + if not rate: + self._rate_field.error = "Rate is required" + self.update() + return + time_unit_str = self._time_unit_field.value + try: + time_unit = TimeUnit[time_unit_str] + except (KeyError, TypeError): + self._time_unit_field.update_error_txt("Required") + self.update() + return + unit_pw = self._unit_pw_field.value + if not unit_pw: + self._unit_pw_field.error = "Required" + self.update() + return + if not self._client: + self._clients_field.update_error_txt("Required") + self.update() + return + billing_str = self._billing_field.value + try: + billing_cycle = Cycle[billing_str] + except (KeyError, TypeError): + self._billing_field.update_error_txt("Required") + self.update() + return + sig_date = self._sig_date_field.get_date() + start_date = self._start_date_field.get_date() + end_date = self._end_date_field.get_date() + if not sig_date or not start_date or not end_date: + return + if end_date < start_date: + return + vat_rate = self._vat_field.value or CONTRACT_DEFAULT_VAT_RATE + + contract = self._entity or Contract() + contract.title = title + contract.signature_date = sig_date + contract.start_date = start_date + contract.end_date = end_date + contract.client = self._client + contract.rate = rate + contract.currency = currency + contract.VAT_rate = vat_rate + contract.unit = time_unit + contract.units_per_workday = unit_pw + contract.volume = self._volume_field.value or None + contract.term_of_payment = self._top_field.value or None + contract.billing_cycle = billing_cycle + + if self._on_save_cb: + self._on_save_cb(contract) class ContractsListView(views.CrudListView): @@ -575,20 +606,58 @@ def __init__(self, params: TViewParams): self.intent = ContractsIntent() super().__init__(params) + def get_side_panel(self): + return ContractSidePanel( + on_close=self._on_panel_closed, + on_save=self._on_contract_saved, + on_delete=self.on_delete_clicked, + intent=self.intent, + client_storage=self.client_storage, + on_edit_requested=self._on_inline_edit_requested, + ) + + def _on_contract_saved(self, contract): + result = self.intent.save_contract(contract) + if result.was_intent_successful: + self.show_snack("Contract saved!") + self._side_panel.close() + self.reload_all_data() + else: + self.show_snack(result.error_msg, is_error=True) + + def get_column_headers(self): + return [ + ("Contract", None), + ("Client", 180), + ("Rate", 160), + ("Billing", 120), + ] + def make_card(self, contract): - return ContractCard( + is_selected = self._selected_entity_id == contract.id + return ContractRow( contract=contract, - on_click_view=lambda cid: self.navigate_to_route( - res_utils.CONTRACT_DETAILS_SCREEN_ROUTE, cid - ), - on_click_edit=lambda cid: self.navigate_to_route( - res_utils.CONTRACT_EDITOR_SCREEN_ROUTE, cid - ), + on_click=lambda cid: self._open_detail(cid), + on_click_edit=lambda cid: self._open_editor(cid), on_click_delete=self._on_delete_by_id, + is_selected=is_selected, ) + def _open_detail(self, contract_id): + if contract_id in self.items_to_display: + self.open_detail_panel(self.items_to_display[contract_id]) + + def _open_editor(self, contract_id): + if contract_id in self.items_to_display: + self.open_edit_panel(self.items_to_display[contract_id]) + + def parent_intent_listener(self, intent: str, data=None): + if intent == res_utils.RELOAD_INTENT: + self.reload_all_data() + elif intent == res_utils.CONTRACT_EDITOR_SCREEN_ROUTE: + self.open_edit_panel(None) + def _on_delete_by_id(self, contract_id): - """Wrap delete_clicked to pass entity object from ID.""" if contract_id in self.items_to_display: self.on_delete_clicked(self.items_to_display[contract_id]) diff --git a/tuttle/app/core/status_bar.py b/tuttle/app/core/status_bar.py new file mode 100644 index 0000000..4b6c69b --- /dev/null +++ b/tuttle/app/core/status_bar.py @@ -0,0 +1,296 @@ +"""Interactive status bar — live business dashboard strip. + +Inspired by VS Code's status bar: every item is clickable and +contextual, showing live business health at a glance. +""" + +import datetime +from typing import Any, Callable, Optional +from dataclasses import dataclass + +from flet import ( + Container, + Icon, + Icons, + Margin, + Padding, + Row, + Text, + MainAxisAlignment, + CrossAxisAlignment, +) + +from ..res import colors, dimens, fonts + + +# ── Status bar item primitives ─────────────────────────────── + + +class StatusBarItem(Container): + """A single clickable item in the status bar.""" + + def __init__( + self, + icon: Optional[Any] = None, + text: str = "", + color: Optional[str] = None, + tooltip: Optional[str] = None, + on_click: Optional[Callable] = None, + icon_size: int = dimens.SM_ICON_SIZE, + visible: bool = True, + ): + text_color = color or colors.text_inverse + controls = [] + if icon: + controls.append( + Icon(icon, size=icon_size, color=text_color), + ) + controls.append( + Text( + text, + size=fonts.STATUS_BAR_SIZE, + color=text_color, + weight=fonts.BOLD_FONT, + ), + ) + super().__init__( + padding=Padding.symmetric( + horizontal=dimens.STATUSBAR_ITEM_PADDING_H, + ), + on_click=on_click, + on_hover=self._on_hover if on_click else None, + tooltip=tooltip, + visible=visible, + border_radius=dimens.RADIUS_SM, + content=Row( + controls=controls, + spacing=dimens.SPACE_XXS, + vertical_alignment=CrossAxisAlignment.CENTER, + ), + ) + self._text_control = controls[-1] + self._icon_control = controls[0] if icon else None + + def _on_hover(self, e): + self.bgcolor = "#20FFFFFF" if e.data == "true" else None + self.update() + + def set_text(self, text: str): + self._text_control.value = text + + def set_color(self, color: str): + self._text_control.color = color + if self._icon_control: + self._icon_control.color = color + + def set_visible(self, visible: bool): + self.visible = visible + + +class StatusBarDivider(Container): + """Thin vertical divider between status bar items.""" + + def __init__(self): + super().__init__( + width=dimens.STATUSBAR_DIVIDER_WIDTH, + height=14, + bgcolor="#40FFFFFF", # 25% white + margin=Margin.symmetric(horizontal=2), + ) + + +# ── StatusBarManager ───────────────────────────────────────── + + +@dataclass +class StatusBarData: + """Holds the current status bar state.""" + + # Left zone + timer_running: bool = False + timer_project: str = "" + timer_start: Optional[datetime.datetime] = None + today_tracked: str = "" + + # Center zone — business health warnings + overdue_count: int = 0 + outstanding_amount: str = "" + expiring_contracts: int = 0 + + # Right zone + entity_count: str = "" + entity_summary: str = "" + + +class StatusBarManager: + """Manages the interactive status bar content. + + Call `update_for_view()` when the active view changes, + and `update_warnings()` to refresh business health indicators. + """ + + def __init__( + self, + on_click_overdue: Optional[Callable] = None, + on_click_outstanding: Optional[Callable] = None, + on_click_expiring: Optional[Callable] = None, + on_click_sync: Optional[Callable] = None, + on_click_quick_add: Optional[Callable] = None, + ): + self._on_click_overdue = on_click_overdue + self._on_click_outstanding = on_click_outstanding + self._on_click_expiring = on_click_expiring + self._on_click_sync = on_click_sync + self._on_click_quick_add = on_click_quick_add + + # ── Left zone items ── + self.entity_count_item = StatusBarItem( + text="Tuttle", + tooltip="Current view", + ) + + # ── Center zone — warnings (hidden by default) ── + self.overdue_item = StatusBarItem( + icon=Icons.WARNING_AMBER_ROUNDED, + text="0 overdue", + color=colors.warning, + tooltip="Click to view overdue invoices", + on_click=on_click_overdue, + visible=False, + ) + self.outstanding_item = StatusBarItem( + icon=Icons.ACCOUNT_BALANCE_WALLET_OUTLINED, + text="€0 outstanding", + tooltip="Click to view unpaid invoices", + on_click=on_click_outstanding, + visible=False, + ) + self.expiring_item = StatusBarItem( + icon=Icons.EVENT_BUSY_OUTLINED, + text="0 ending soon", + color=colors.warning, + tooltip="Click to view expiring contracts", + on_click=on_click_expiring, + visible=False, + ) + + # ── Right zone items ── + self.entity_summary_item = StatusBarItem( + text="", + visible=False, + ) + + self._warning_divider = StatusBarDivider() + self._warning_divider.visible = False + + self._right_divider = StatusBarDivider() + self._right_divider.visible = False + + def build(self) -> Container: + """Build the status bar container.""" + self.bar = Container( + height=dimens.FOOTER_HEIGHT, + bgcolor=colors.bg_statusbar, + padding=Padding.symmetric(horizontal=dimens.SPACE_XS), + content=Row( + controls=[ + # Left zone + Row( + controls=[ + self.entity_count_item, + ], + spacing=0, + vertical_alignment=CrossAxisAlignment.CENTER, + ), + # Center zone — warnings + Row( + controls=[ + self._warning_divider, + self.overdue_item, + self.outstanding_item, + self.expiring_item, + ], + spacing=0, + vertical_alignment=CrossAxisAlignment.CENTER, + ), + # Right zone + Row( + controls=[ + self._right_divider, + self.entity_summary_item, + ], + spacing=0, + vertical_alignment=CrossAxisAlignment.CENTER, + expand=True, + alignment=MainAxisAlignment.END, + ), + ], + spacing=0, + alignment=MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=CrossAxisAlignment.CENTER, + ), + ) + return self.bar + + def update_for_view( + self, + entity_count_text: str, + summary_text: str = "", + ): + """Update status bar text for the currently active view.""" + self.entity_count_item.set_text(entity_count_text) + + if summary_text: + self.entity_summary_item.set_text(summary_text) + self.entity_summary_item.visible = True + self._right_divider.visible = True + else: + self.entity_summary_item.visible = False + self._right_divider.visible = False + + def update_warnings( + self, + overdue_count: int = 0, + outstanding_text: str = "", + expiring_count: int = 0, + ): + """Update the business health warning indicators.""" + has_warnings = overdue_count > 0 or bool(outstanding_text) or expiring_count > 0 + + # Overdue invoices + if overdue_count > 0: + self.overdue_item.set_text(f"{overdue_count} overdue") + self.overdue_item.visible = True + else: + self.overdue_item.visible = False + + # Outstanding amount + if outstanding_text: + self.outstanding_item.set_text(outstanding_text) + self.outstanding_item.visible = True + else: + self.outstanding_item.visible = False + + # Expiring contracts + if expiring_count > 0: + self.expiring_item.set_text(f"{expiring_count} ending soon") + self.expiring_item.visible = True + else: + self.expiring_item.visible = False + + self._warning_divider.visible = has_warnings + + # Update bar color based on urgency + if overdue_count > 0: + self.bar.bgcolor = colors.bg_statusbar_danger + elif has_warnings: + self.bar.bgcolor = colors.bg_statusbar_warning + else: + self.bar.bgcolor = colors.bg_statusbar + + def try_update(self): + """Try to push visual updates to the bar.""" + try: + self.bar.update() + except Exception: + pass diff --git a/tuttle/app/core/views.py b/tuttle/app/core/views.py index cb7dad4..65aa312 100644 --- a/tuttle/app/core/views.py +++ b/tuttle/app/core/views.py @@ -21,10 +21,10 @@ DropdownOption, ElevatedButton, FilledButton, - GridView, Icon, Icons, Image, + ListView, MainAxisAlignment, Margin, Padding, @@ -37,6 +37,7 @@ Row, Text, TextField, + TextButton, TextStyle, Control, RoundedRectangleBorder, @@ -416,26 +417,6 @@ def __init__( self.width = width self.hint = hint self.options = [DropdownOption(text=item) for item in items] - - def update_dropdown_items(self, items: List[str]): - self.options = [DropdownOption(text=item) for item in items] - self.drop_down.options = self.options - self.update() - - def update_value(self, new_value: str): - self.drop_down.value = new_value - self.drop_down.error_text = None - self.update() - - @property - def value(self): - return self.drop_down.value - - def update_error_txt(self, error_txt: str = ""): - self.drop_down.error_text = error_txt if error_txt else None - self.update() - - def build(self): self.drop_down = Dropdown( label=self.label, hint_text=self.hint, @@ -458,6 +439,25 @@ def build(self): border_radius=dimens.RADIUS_MD, color=colors.text_primary, ) + + def update_dropdown_items(self, items: List[str]): + self.options = [DropdownOption(text=item) for item in items] + self.drop_down.options = self.options + self.update() + + def update_value(self, new_value: str): + self.drop_down.value = new_value + self.drop_down.error_text = None + + @property + def value(self): + return self.drop_down.value + + def update_error_txt(self, error_txt: str = ""): + self.drop_down.error_text = error_txt if error_txt else None + self.update() + + def build(self): self.controls = [self.drop_down] @@ -491,6 +491,20 @@ def __init__( on_change=self._on_picked, ) + display = ( + self._selected_date.strftime(self._DATE_FMT) + if self._selected_date + else "Select date" + ) + display_color = ( + colors.text_primary if self._selected_date else colors.text_muted + ) + self._date_text = Text( + value=display, + size=fonts.BODY_1_SIZE, + color=display_color, + ) + def _on_picked(self, e): picked = e.control.value if picked is not None: @@ -510,21 +524,6 @@ def _open_picker(self, e): self.page.show_dialog(self._picker) def build(self): - display = ( - self._selected_date.strftime(self._DATE_FMT) - if self._selected_date - else "Select date" - ) - display_color = ( - colors.text_primary if self._selected_date else colors.text_muted - ) - - self._date_text = Text( - value=display, - size=fonts.BODY_1_SIZE, - color=display_color, - ) - self.content = Column( spacing=dimens.SPACE_XXS, controls=[ @@ -572,7 +571,6 @@ def set_date(self, date: Optional[datetime.date] = None): self._picker.value = datetime.datetime.combine(date, datetime.time()) self._date_text.value = date.strftime(self._DATE_FMT) self._date_text.color = colors.text_primary - self.update() def get_date(self) -> Optional[datetime.date]: return self._selected_date @@ -754,25 +752,29 @@ class NavigationMenuItem: class SectionLabel(Container): - """Uppercase muted section header (like VS Code sidebar sections).""" + """Uppercase muted section header — macOS sidebar style.""" def __init__(self, title: str): super().__init__( padding=Padding.only( - left=dimens.SPACE_MD, top=dimens.SPACE_MD, bottom=dimens.SPACE_SM + left=dimens.SPACE_STD, top=dimens.SPACE_LG, bottom=dimens.SPACE_XS ), content=Text( title.upper(), - size=fonts.OVERLINE_SIZE, + size=fonts.CAPTION_SIZE, color=colors.text_muted, - weight=fonts.BOLDER_FONT, + weight=fonts.BOLD_FONT, style=TextStyle(letter_spacing=1.2), ), ) class SidebarNavItem(Container): - """A single sidebar navigation item — flat, with hover highlight.""" + """A single sidebar navigation item — macOS-native feel.""" + + # Semi-transparent white tint for selected state (native macOS style) + _SELECTED_BG = "#14FFFFFF" # ~8% white + _HOVER_BG = "#0AFFFFFF" # ~4% white def __init__( self, @@ -787,24 +789,29 @@ def __init__( self._selected_icon = selected_icon self._on_click = on_click - bg = colors.accent_muted if selected else None - icon_color = colors.text_inverse if selected else colors.text_secondary + bg = self._SELECTED_BG if selected else None + icon_color = colors.text_inverse if selected else colors.text_muted text_color = colors.text_primary if selected else colors.text_secondary current_icon = selected_icon if selected else icon super().__init__( bgcolor=bg, - border_radius=dimens.RADIUS_MD, + border_radius=dimens.RADIUS_LG, padding=Padding.symmetric( - horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XS + 2 + horizontal=dimens.SPACE_SM + 2, vertical=dimens.SPACE_XS + 2 ), - margin=Margin.symmetric(horizontal=dimens.SPACE_SM), + margin=Margin.symmetric(horizontal=dimens.SPACE_XS, vertical=1), on_click=on_click, on_hover=self._on_hover, content=Row( controls=[ Icon(current_icon, size=dimens.ICON_SIZE, color=icon_color), - Text(label, size=fonts.BODY_1_SIZE, color=text_color), + Text( + label, + size=fonts.BODY_1_SIZE, + color=text_color, + weight=fonts.BOLD_FONT if selected else None, + ), ], spacing=dimens.SPACE_SM, vertical_alignment=utils.CENTER_ALIGNMENT, @@ -813,7 +820,7 @@ def __init__( def _on_hover(self, e): if not self._selected: - self.bgcolor = colors.bg_surface_hovered if e.data == "true" else None + self.bgcolor = self._HOVER_BG if e.data == "true" else None self.update() @@ -992,12 +999,11 @@ def __init__(self, form_controls: list[Control]): content=Container( expand=True, bgcolor=colors.bg_surface, - border=Border.all(dimens.CARD_BORDER_WIDTH, colors.border), - border_radius=dimens.RADIUS_LG, + border_radius=dimens.RADIUS_XL, content=Container( Column(expand=True, controls=form_controls), padding=Padding.all(dimens.SPACE_LG), - width=800, + width=720, ), ), ) @@ -1030,49 +1036,298 @@ def tooltip(self): class EntityFiltersView(Row): - """Segmented-control-style filter bar for entity lists.""" + """Compact text-tab filter bar for entity lists — macOS style.""" def __init__(self, on_state_changed: Callable, states_enum=EntityStates): super().__init__() self.states_enum = states_enum self.current_state = states_enum.ALL self.on_state_changed = on_state_changed - self.filter_buttons = {} def on_filter_button_clicked(self, state): self.current_state = state - self.set_filter_buttons() + self._rebuild_chips() self.on_state_changed(state) self.update() - def set_filter_buttons(self): + def _rebuild_chips(self): + """Rebuild chip controls into the inner row, like invoicing does.""" + chips = [] for state in self.states_enum: is_active = self.current_state == state - self.filter_buttons[state] = ElevatedButton( - content=str(state), - col={"xs": 6, "sm": 3, "lg": 2}, - on_click=lambda e, s=state: self.on_filter_button_clicked(s), - height=dimens.CLICKABLE_PILL_HEIGHT, - color=colors.text_inverse if is_active else colors.text_secondary, - bgcolor=colors.accent if is_active else colors.bg_surface, - tooltip=state.tooltip, - style=ButtonStyle( - shape=RoundedRectangleBorder(radius=dimens.RADIUS_SM), - elevation=0, - side=BorderSide( - width=1, - color=colors.accent if is_active else colors.border, + chips.append( + Container( + on_click=lambda e, s=state: self.on_filter_button_clicked(s), + border_radius=dimens.RADIUS_PILL, + padding=Padding.symmetric( + horizontal=dimens.SPACE_SM, + vertical=dimens.SPACE_XXS, ), - ), + bgcolor=colors.accent if is_active else colors.bg_input, + content=Text( + str(state), + size=fonts.CAPTION_SIZE, + color=colors.text_inverse + if is_active + else colors.text_secondary, + weight=fonts.BOLD_FONT if is_active else None, + ), + tooltip=state.tooltip, + ) ) + self._chip_row.controls = chips def build(self): - self.set_filter_buttons() - self.controls = [ - ResponsiveRow( - controls=list(self.filter_buttons.values()), + self._chip_row = Row( + controls=[], + spacing=dimens.SPACE_XXS, + ) + self._rebuild_chips() + self.controls = [self._chip_row] + + +# --------------------------------------------------------------------------- +# EntitySidePanel — unified right-side panel for detail & edit +# --------------------------------------------------------------------------- + + +class EntitySidePanel(Container): + """Slide-in right-side panel for viewing and editing entities. + + Modeled after PdfViewerPanel: hidden by default, uses ``visible`` toggle. + Sits inside a ``Row`` next to the entity grid. When visible the grid + shrinks and the panel fills the remaining space. + + Subclasses override: + - ``build_detail_content(entity)`` -> list[Control] + - ``build_edit_content(entity)`` -> list[Control] + - ``on_save(entity)`` -> handle save result + """ + + def __init__( + self, + on_close: Callable, + on_save: Optional[Callable] = None, + on_delete: Optional[Callable] = None, + on_edit_requested: Optional[Callable] = None, + ): + self._on_close = on_close + self._on_save_cb = on_save + self._on_delete_cb = on_delete + self._on_edit_requested = on_edit_requested + self._entity = None + self._mode = "view" # "view" or "edit" + # When used as an inline content builder (not mounted in tree), + # update() calls are routed through this container instead. + self._inline_container: Optional[Container] = None + + # Header + self._title_text = THeading(title="", size=fonts.HEADLINE_4_SIZE) + self._close_btn = IconButton( + icon=Icons.CLOSE, + icon_size=dimens.ICON_SIZE, + icon_color=colors.text_secondary, + tooltip="Close", + on_click=lambda e: self.close(), + ) + self._edit_btn = IconButton( + icon=Icons.EDIT_OUTLINED, + icon_size=dimens.ICON_SIZE, + icon_color=colors.text_secondary, + tooltip="Edit", + on_click=lambda e: self._switch_to_edit(), + ) + self._header = Row( + alignment=MainAxisAlignment.SPACE_BETWEEN, + vertical_alignment=CrossAxisAlignment.CENTER, + controls=[ + self._title_text, + Row( + spacing=0, + controls=[self._edit_btn, self._close_btn], + ), + ], + ) + + # Scrollable body + self._body = ListView(expand=True, spacing=dimens.SPACE_XS) + + super().__init__( + visible=False, + width=400, + bgcolor=colors.bg_surface, + border=Border(left=BorderSide(1, colors.border)), + border_radius=BorderRadius( + top_left=dimens.RADIUS_LG, + bottom_left=dimens.RADIUS_LG, + top_right=0, + bottom_right=0, + ), + padding=Padding.symmetric( + horizontal=dimens.SPACE_MD, vertical=dimens.SPACE_SM + ), + content=Column( + expand=True, + spacing=0, + controls=[ + self._header, + Container(height=dimens.SPACE_XS), + self._body, + ], + ), + ) + + # -- Public API ----------------------------------------------------------- + + def show_detail(self, entity, title: str = ""): + """Open the panel in view mode for *entity*.""" + self._entity = entity + self._mode = "view" + self._title_text.value = title or str(entity) + self._edit_btn.visible = True + self._body.controls = self.build_detail_content(entity) + self.visible = True + + def show_editor(self, entity=None, title: str = ""): + """Open the panel in edit mode, optionally pre-filled with *entity*.""" + self._entity = entity + self._mode = "edit" + is_new = entity is None + self._title_text.value = title or ("New" if is_new else "Edit") + self._edit_btn.visible = False + self._body.controls = self.build_edit_content(entity) + self.visible = True + + def close(self): + """Hide the panel and notify the parent.""" + self.visible = False + self._body.controls.clear() + self._entity = None + self._on_close() + + def update(self): + """Safe update — routes through inline container when panel isn't mounted.""" + try: + super().update() + except Exception: + if self._inline_container and self._inline_container.page: + self._inline_container.update() + + # -- Subclass hooks ------------------------------------------------------- + + def build_detail_content(self, entity) -> list[Control]: + """Return controls for the read-only detail view. Override me.""" + return [TBodyText(txt=str(entity))] + + def build_compact_detail(self, entity) -> list[Control]: + """Return controls for compact inline detail. Override for grid layouts. + + Defaults to build_detail_content(). Override in subclasses to use + multi-column ResponsiveRow layouts optimised for full-width inline + display. + """ + return self.build_detail_content(entity) + + def _compact_field(self, label: str, value: str, col: dict = None) -> Column: + """A label + value pair sized for ResponsiveRow columns.""" + if col is None: + col = {"xs": 6, "sm": 4, "md": 3} + return Column( + col=col, + spacing=1, + controls=[ + Text( + label, + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=FontWeight.W_600, + ), + Text( + value or "—", + size=fonts.BODY_2_SIZE, + color=colors.text_primary if value else colors.text_muted, + ), + ], + ) + + def build_edit_content(self, entity) -> list[Control]: + """Return controls for the edit/create form. Override me.""" + return [TBodyText(txt="Editor not implemented")] + + @staticmethod + def _edit_action_bar( + save_label: str, + on_save: Callable, + on_cancel: Callable, + ) -> Row: + """Compact right-aligned Save / Cancel action bar for inline edit.""" + return Row( + alignment=MainAxisAlignment.END, + spacing=dimens.SPACE_SM, + controls=[ + TextButton( + content=Text("Cancel", size=fonts.BODY_2_SIZE), + on_click=on_cancel, + ), + TPrimaryButton(label=save_label, on_click=on_save), + ], + ) + + def _switch_to_edit(self): + """Toggle from view mode to edit mode.""" + if self._on_edit_requested and self._entity: + self._on_edit_requested(self._entity) + elif self._entity: + self.show_editor(self._entity, title="Edit") + self.update() + + def _get_detail_field(self, label: str, value: str, icon=None) -> Container: + """Helper: a styled label + value row for detail view.""" + controls = [] + if icon: + controls.append( + Icon(icon, size=dimens.SM_ICON_SIZE, color=colors.text_muted) + ) + controls.append( + Text( + label, + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=FontWeight.W_600, ) - ] + ) + return Container( + padding=Padding.symmetric(vertical=2), + content=Column( + spacing=2, + controls=[ + Row(spacing=dimens.SPACE_XXS, controls=controls), + Text( + value or "—", + size=fonts.BODY_2_SIZE, + color=colors.text_primary if value else colors.text_muted, + ), + ], + ), + ) + + def _get_section_divider(self) -> Container: + """Thin horizontal divider between sections.""" + return Container( + height=1, + bgcolor=colors.border, + margin=Margin.symmetric(vertical=dimens.SPACE_XS), + ) + + def _get_action_bar(self, *buttons) -> Container: + """Bottom action bar with buttons.""" + return Container( + padding=Padding.only(top=dimens.SPACE_SM), + content=Row( + spacing=dimens.SPACE_SM, + controls=list(buttons), + ), + ) class CrudListView(TView, Column): @@ -1084,10 +1339,10 @@ class CrudListView(TView, Column): - entity_name_plural: str (e.g. "projects") Subclasses must implement: - - make_card(entity) -> Card control - - get_card_callbacks() -> dict of callbacks for make_card (optional override) + - make_card(entity) -> row control Optional overrides: + - get_column_headers() -> list[tuple[str, int|None]] or None - get_filters_view() -> Control or None (for filter bar) - on_add_intent_key -> str or None (res_utils intent key for add action) - open_add_editor(data) -> open inline editor for add @@ -1107,6 +1362,14 @@ def get_sortable_fields(self) -> list[tuple[str, Callable]]: """ return [] + def get_column_headers(self) -> Optional[list[tuple[str, Optional[int]]]]: + """Return column headers as (label, width_or_None_for_expand). + + Override in subclasses. Example: + [("Title", None), ("Client", 200), ("Dates", 180)] + """ + return None + def __init__(self, params: TViewParams): TView.__init__(self, params) Column.__init__(self) @@ -1159,7 +1422,8 @@ def __init__(self, params: TViewParams): controls=[ THeading( f"My {self.entity_name_plural.title()}", - size=fonts.HEADLINE_3_SIZE, + size=fonts.HEADLINE_2_SIZE, + color=colors.text_secondary, ), ] + ([sort_control] if sort_control else []), @@ -1177,11 +1441,13 @@ def __init__(self, params: TViewParams): ) ] ) - self.items_container = GridView( - max_extent=dimens.CARD_MAX_EXTENT, - spacing=dimens.CARD_SPACING, - run_spacing=dimens.CARD_SPACING, + self.items_container = ListView( + expand=True, + spacing=0, ) + self._selected_entity_id = None + self._expanded_mode = None # None | "detail" | "edit" + self._inline_expansion = None # cached expansion Container self.items_to_display = {} self.popup_handler = None @@ -1240,9 +1506,26 @@ def refresh_list(self): key=lambda ent: (key_func(ent) is None, key_func(ent)), reverse=not self._sort_ascending, ) + + # If creating a new entity (no selected row), show edit form at top + if ( + self._expanded_mode == "edit" + and self._selected_entity_id is None + and self._inline_expansion is not None + ): + self.items_container.controls.append(self._inline_expansion) + for entity in entities: card = self.make_card(entity) self.items_container.controls.append(card) + # Insert inline expansion right after the selected row + eid = getattr(entity, "id", None) + if ( + eid is not None + and eid == self._selected_entity_id + and self._inline_expansion is not None + ): + self.items_container.controls.append(self._inline_expansion) def on_delete_clicked(self, entity): """Opens delete confirmation popup.""" @@ -1314,17 +1597,119 @@ def reload_all_data(self): self.loading_indicator.visible = False self.update_self() + # -- Inline expansion (replaces side panel) --------------------------------- + + def get_side_panel(self) -> Optional[EntitySidePanel]: + """Override to return a panel used as inline content builder.""" + return None + + def _collapse(self): + """Collapse any open inline expansion.""" + self._selected_entity_id = None + self._expanded_mode = None + self._inline_expansion = None + self.refresh_list() + self.update_self() + + def _on_panel_closed(self): + """Called when the panel's close/cancel button is pressed.""" + self._collapse() + + def _on_inline_edit_requested(self, entity): + """Switch the inline expansion from detail to edit mode.""" + self._expanded_mode = "edit" + self._inline_expansion = self._build_inline_expansion(entity) + self.refresh_list() + self.update_self() + + def open_detail_panel(self, entity): + """Toggle inline detail expansion for *entity*.""" + eid = getattr(entity, "id", None) + if eid == self._selected_entity_id and self._expanded_mode == "detail": + # Already expanded — collapse + self._collapse() + return + self._selected_entity_id = eid + self._expanded_mode = "detail" + if self._side_panel: + self._side_panel._entity = entity + self._inline_expansion = self._build_inline_expansion(entity) + self.refresh_list() + self.update_self() + + def open_edit_panel(self, entity=None): + """Show inline edit form (new or existing entity).""" + self._selected_entity_id = getattr(entity, "id", None) if entity else None + self._expanded_mode = "edit" + if self._side_panel: + self._side_panel._entity = entity + self._inline_expansion = self._build_inline_expansion(entity) + self.refresh_list() + self.update_self() + + def _build_inline_expansion(self, entity) -> Optional[Container]: + """Build the inline detail/edit container from the panel's content.""" + if not self._side_panel: + return None + + if self._expanded_mode == "edit": + content_controls = self._side_panel.build_edit_content(entity) + else: + content_controls = self._side_panel.build_compact_detail(entity) + + expansion = Container( + bgcolor=colors.bg_surface, + border=Border(bottom=BorderSide(1, colors.border)), + padding=Padding.symmetric( + horizontal=dimens.SPACE_LG, vertical=dimens.SPACE_SM + ), + content=Column( + spacing=dimens.SPACE_XXS, + controls=content_controls, + ), + ) + # Let the panel route update() calls through this container + self._side_panel._inline_container = expansion + return expansion + def build(self): + self._side_panel = self.get_side_panel() filters = self.get_filters_view() controls = [self.title_control, Spacer(md_space=True)] if filters: controls.append(filters) - controls.append( - Container( - expand=True, - content=self.items_container, + + # Column header row + col_headers = self.get_column_headers() + if col_headers: + header_cells = [] + for label, width in col_headers: + cell = Text( + label.upper(), + size=fonts.CAPTION_SIZE, + color=colors.text_muted, + weight=FontWeight.W_600, + ) + if width: + header_cells.append(Container(width=width, content=cell)) + else: + header_cells.append(Container(expand=True, content=cell)) + header_row = Container( + padding=Padding.symmetric( + horizontal=dimens.SPACE_MD, vertical=dimens.SPACE_XS + ), + border=Border(bottom=BorderSide(1, colors.border)), + content=Row( + controls=header_cells, + spacing=dimens.SPACE_MD, + vertical_alignment=CrossAxisAlignment.CENTER, + ), ) - ) + controls.append(header_row) + + list_container = Container(expand=True, content=self.items_container) + controls.append(list_container) + self.controls = controls def will_unmount(self): diff --git a/tuttle/app/home/view.py b/tuttle/app/home/view.py index fe8060a..3c7c35d 100644 --- a/tuttle/app/home/view.py +++ b/tuttle/app/home/view.py @@ -21,6 +21,7 @@ ResponsiveRow, Row, Text, + TextButton, Control, ScrollMode, MainAxisAlignment, @@ -33,6 +34,7 @@ from ..contracts.view import ContractsListView from ..core import utils, views from ..core.abstractions import DialogHandler, TView, TViewParams +from ..core.status_bar import StatusBarManager from ..invoicing.view import InvoicingListView from ..projects.view import ProjectsListView from ..res import colors, dimens, fonts, res_utils, theme @@ -42,60 +44,53 @@ def get_toolbar( - title: str, on_click_new_btn: Callable, on_click_profile_btn: Callable, on_view_settings_clicked: Callable, ): - """Compact toolbar — title on the left, actions on the right.""" + """Slim toolbar — actions only, no redundant title.""" return Container( alignment=Alignment.CENTER, height=dimens.TOOLBAR_HEIGHT, - bgcolor=colors.bg_toolbar, - padding=Padding.symmetric(horizontal=dimens.SPACE_MD), - border=Border.only( - bottom=BorderSide(width=1, color=colors.border_subtle), - ), + bgcolor=colors.bg, + padding=Padding.symmetric(horizontal=dimens.SPACE_LG), content=Row( - alignment=utils.SPACE_BETWEEN_ALIGNMENT, + alignment=MainAxisAlignment.END, vertical_alignment=CrossAxisAlignment.CENTER, controls=[ Row( + spacing=dimens.SPACE_XXS, controls=[ - views.THeading(title, size=fonts.HEADLINE_4_SIZE), - ], - vertical_alignment=CrossAxisAlignment.CENTER, - ), - Row( - spacing=dimens.SPACE_XS, - controls=[ - ElevatedButton( - content="New", - icon=Icons.ADD, - icon_color=colors.accent, - color=colors.accent, - bgcolor=colors.bg_surface, - height=dimens.CLICKABLE_STD_HEIGHT, - on_click=on_click_new_btn, - style=views.ButtonStyle( - shape=views.RoundedRectangleBorder( - radius=dimens.RADIUS_MD - ), - side=BorderSide(width=1, color=colors.border), - elevation=0, + TextButton( + content=Row( + controls=[ + Icon( + Icons.ADD, + size=dimens.SM_ICON_SIZE, + color=colors.accent, + ), + Text( + "New", + size=fonts.BODY_1_SIZE, + color=colors.accent, + weight=fonts.BOLD_FONT, + ), + ], + spacing=dimens.SPACE_XXS, ), + on_click=on_click_new_btn, ), IconButton( icon=Icons.SETTINGS_OUTLINED, icon_size=dimens.ICON_SIZE, - icon_color=colors.text_secondary, + icon_color=colors.text_muted, on_click=on_view_settings_clicked, tooltip="Preferences", ), IconButton( icon=Icons.PERSON_OUTLINE_OUTLINED, icon_size=dimens.ICON_SIZE, - icon_color=colors.text_secondary, + icon_color=colors.text_muted, tooltip="Profile", on_click=on_click_profile_btn, ), @@ -144,7 +139,7 @@ def __init__(self, params: TViewParams): icon=utils.TuttleComponentIcons.project_icon, selected_icon=utils.TuttleComponentIcons.project_selected_icon, destination=self.projects_view, - on_new_screen_route=res_utils.PROJECT_EDITOR_SCREEN_ROUTE, + on_new_intent=res_utils.PROJECT_EDITOR_SCREEN_ROUTE, ), views.NavigationMenuItem( index=1, @@ -152,7 +147,7 @@ def __init__(self, params: TViewParams): icon=utils.TuttleComponentIcons.contract_icon, selected_icon=utils.TuttleComponentIcons.contract_selected_icon, destination=self.contracts_view, - on_new_screen_route=res_utils.CONTRACT_EDITOR_SCREEN_ROUTE, + on_new_intent=res_utils.CONTRACT_EDITOR_SCREEN_ROUTE, ), views.NavigationMenuItem( index=2, @@ -236,10 +231,14 @@ def __init__(self, params: TViewParams): self.destination_view = self._all_items[0].destination self.dialog: Optional[DialogHandler] = None - # Toolbar (title updates on nav change) - self._toolbar_title = self._all_items[0].label + # Status bar manager + self.status_bar_manager = StatusBarManager( + on_click_overdue=lambda e: self._jump_to_invoicing(), + on_click_outstanding=lambda e: self._jump_to_invoicing(), + ) + + # Toolbar (slim, no title — view heading is the title) self.toolbar = get_toolbar( - title=self._toolbar_title, on_click_new_btn=self.on_click_add_new, on_click_profile_btn=self.on_click_profile, on_view_settings_clicked=self.on_view_settings_clicked, @@ -250,15 +249,7 @@ def _on_sidebar_item_selected(self, item: views.NavigationMenuItem): self._selected_flat_index = self._all_items.index(item) self.destination_view = item.destination self.destination_content_container.content = self.destination_view - # Update toolbar title - self._toolbar_title = item.label - self.toolbar = get_toolbar( - title=self._toolbar_title, - on_click_new_btn=self.on_click_add_new, - on_click_profile_btn=self.on_click_profile, - on_view_settings_clicked=self.on_view_settings_clicked, - ) - self.main_body.controls = [self.toolbar, self.destination_content_container] + self._update_status_bar_for_view(item.label) self.update_self() # ── Action buttons ──────────────────────────────────────── @@ -319,9 +310,6 @@ def build(self): spacing=0, expand=True, ), - border=Border.only( - right=BorderSide(width=1, color=colors.border_subtle), - ), ) # Main body @@ -360,6 +348,98 @@ def build(self): def did_mount(self): self.mounted = True + first_label = self._all_items[0].label if self._all_items else "" + self._update_status_bar_for_view(first_label) + + def _update_status_bar_for_view(self, view_label: str): + """Update the status bar to reflect the active view's context.""" + count_text = view_label + summary = "" + + try: + if view_label == "Projects": + view = self.main_menu_handler.projects_view + n = len(getattr(view, "items_to_display", {})) + if n > 0: + count_text = f"{n} Project{'s' if n != 1 else ''}" + active = sum( + 1 for p in view.items_to_display.values() if p.is_active() + ) + completed = sum( + 1 for p in view.items_to_display.values() if p.is_completed + ) + parts = [] + if active: + parts.append(f"{active} Active") + if completed: + parts.append(f"{completed} Completed") + summary = " \u00b7 ".join(parts) + elif view_label == "Contracts": + view = self.main_menu_handler.contracts_view + n = len(getattr(view, "items_to_display", {})) + if n > 0: + count_text = f"{n} Contract{'s' if n != 1 else ''}" + elif view_label == "Clients": + view = self.main_menu_handler.clients_view + n = len(getattr(view, "items_to_display", {})) + if n > 0: + count_text = f"{n} Client{'s' if n != 1 else ''}" + elif view_label == "Contacts": + view = self.main_menu_handler.contacts_view + n = len(getattr(view, "items_to_display", {})) + if n > 0: + count_text = f"{n} Contact{'s' if n != 1 else ''}" + elif view_label == "Invoicing": + view = self.secondary_menu_handler.invoicing_view + invoices = getattr(view, "all_invoices", {}) + n = len(invoices) + if n > 0: + count_text = f"{n} Invoice{'s' if n != 1 else ''}" + overdue = sum( + 1 + for inv in invoices.values() + if not inv.paid + and not inv.cancelled + and inv.due_date + and inv.due_date < __import__("datetime").date.today() + ) + unpaid = sum( + 1 + for inv in invoices.values() + if not inv.paid and not inv.cancelled + ) + parts = [] + if unpaid: + parts.append(f"{unpaid} Unpaid") + summary = " \u00b7 ".join(parts) + self.status_bar_manager.update_warnings( + overdue_count=overdue, + ) + else: + self.status_bar_manager.update_warnings() + elif view_label == "Time Tracking": + count_text = "Time Tracking" + except Exception: + pass + + self.status_bar_manager.update_for_view( + entity_count_text=count_text, + summary_text=summary, + ) + self.status_bar_manager.try_update() + + def _jump_to_invoicing(self): + """Jump to the invoicing view from status bar.""" + # Find invoicing in the items list + for i, item in enumerate(self._all_items): + if item.label == "Invoicing": + self._on_sidebar_item_selected(item) + self.sidebar_panel._handle_click( + self.sidebar_panel._flat_items.index(item) + if item in self.sidebar_panel._flat_items + else i + ) + break def on_resume_after_back_pressed(self): self.pass_intent_to_destination(res_utils.RELOAD_INTENT) diff --git a/tuttle/app/invoicing/view.py b/tuttle/app/invoicing/view.py index 274b831..46cac4c 100644 --- a/tuttle/app/invoicing/view.py +++ b/tuttle/app/invoicing/view.py @@ -129,7 +129,7 @@ def __init__(self, label: str, active: bool = False, on_click=None): self._label = label self._active = active super().__init__( - bgcolor=colors.accent_muted if active else colors.bg_input, + bgcolor=colors.accent if active else colors.bg_input, border_radius=dimens.RADIUS_PILL, padding=Padding.symmetric( horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XXS @@ -950,7 +950,7 @@ def build(self): self.title_control = Row( controls=[ - views.THeading(title="Invoicing", size=fonts.HEADLINE_4_SIZE), + views.THeading(title="Invoicing", size=fonts.HEADLINE_2_SIZE), ], ) diff --git a/tuttle/app/projects/view.py b/tuttle/app/projects/view.py index 6140a09..a2b371a 100644 --- a/tuttle/app/projects/view.py +++ b/tuttle/app/projects/view.py @@ -6,6 +6,7 @@ from flet import ( ButtonStyle, Card, + ClipBehavior, Column, Container, ElevatedButton, @@ -20,11 +21,13 @@ Control, Alignment, Border, + BorderSide, Icons, Padding, + MainAxisAlignment, + CrossAxisAlignment, ) -from ..clients.view import ClientViewPopUp from ..core import utils, views from ..core.abstractions import TView, TViewParams from ..core.intent_result import IntentResult @@ -40,333 +43,432 @@ def _project_initials(title: str) -> str: return "".join(p[0].upper() for p in parts[:2]) if parts else "?" -class ProjectCard(Container): - """Flat, bordered card for a project — VS Code panel style.""" +class ProjectRow(Container): + """Single-line list row for a project — macOS native table style.""" def __init__( - self, project, on_view_details_clicked, on_delete_clicked, on_edit_clicked + self, project, on_click, on_delete_clicked, on_edit_clicked, is_selected=False ): self.project: Project = project - self.on_view_details_clicked = on_view_details_clicked - self.on_delete_clicked = on_delete_clicked - self.on_edit_clicked = on_edit_clicked - - _contract_title = "Unknown contract" - if project.contract: - _contract_title = project.contract.title - _client_title = "Unknown client" - if project.client: - _client_title = project.client.name - - initials = _project_initials(project.title) - avatar = Container( - width=36, - height=36, - bgcolor=colors.accent_muted, - border_radius=dimens.RADIUS_LG, - alignment=Alignment.CENTER, - content=Text( - initials, - size=fonts.BODY_1_SIZE, - color=colors.accent, - weight=fonts.BOLD_FONT, - ), - ) - - header = Row( - controls=[ - avatar, - Column( - spacing=0, - controls=[ - views.TBodyText( - utils.truncate_str(project.title, 30), - weight=fonts.BOLD_FONT, - ), - views.TBodyText( - project.tag or "", - color=colors.text_secondary, - size=fonts.BODY_2_SIZE, - ), - ], - ), - ], - spacing=dimens.SPACE_SM, - expand=True, - vertical_alignment=utils.CENTER_ALIGNMENT, - ) - context_menu = views.TContextMenu( - on_click_view=lambda e: self.on_view_details_clicked(project.id), - on_click_delete=lambda e: self.on_delete_clicked(project.id), - on_click_edit=lambda e: self.on_edit_clicked(project.id), + _contract_title = project.contract.title if project.contract else "—" + _client_title = project.client.name if project.client else "—" + + _status = project.get_status() + _dot_color = { + "Active": colors.status_active, + "Upcoming": colors.status_upcoming, + "Completed": colors.status_completed, + }.get(_status, colors.text_muted) + + status_dot = Container( + width=8, + height=8, + bgcolor=_dot_color, + border_radius=dimens.RADIUS_PILL, ) - # Info rows - def _info_row(label, value): - return Column( - spacing=2, - controls=[ - views.TBodyText( - label, color=colors.text_muted, size=fonts.OVERLINE_SIZE - ), - views.TBodyText(value, size=fonts.BODY_2_SIZE), - ], - ) - start_str = ( project.start_date.strftime("%d/%m/%Y") if project.start_date else "" ) - end_str = project.end_date.strftime("%d/%m/%Y") if project.end_date else "-" + end_str = project.end_date.strftime("%d/%m/%Y") if project.end_date else "" + date_str = f"{start_str} → {end_str}" if start_str else "—" - body_items = [ - _info_row("Client", _client_title), - _info_row("Contract", _contract_title), - Row( - spacing=dimens.SPACE_MD, - controls=[ - _info_row("Start", start_str), - _info_row("End", end_str), - ], - ), - ] + _bg = colors.accent_muted if is_selected else colors.bg super().__init__( - expand=True, - bgcolor=colors.bg_surface, - border=Border.all(dimens.CARD_BORDER_WIDTH, colors.border), - border_radius=dimens.RADIUS_LG, - padding=Padding.all(dimens.SPACE_MD), + bgcolor=_bg, + border=Border(bottom=BorderSide(1, colors.border)), + padding=Padding.symmetric( + horizontal=dimens.SPACE_MD, vertical=dimens.SPACE_SM + ), + on_click=lambda e: on_click(project.id), on_hover=self._on_hover, - on_click=lambda e: self.on_view_details_clicked(project.id), - content=Column( - spacing=dimens.SPACE_SM, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Row( + spacing=dimens.SPACE_MD, + vertical_alignment=utils.CENTER_ALIGNMENT, controls=[ - Row( - controls=[header, context_menu], - alignment=utils.SPACE_BETWEEN_ALIGNMENT, - vertical_alignment=utils.START_ALIGNMENT, + Container( + expand=True, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Row( + spacing=dimens.SPACE_XS, + vertical_alignment=utils.CENTER_ALIGNMENT, + controls=[ + status_dot, + Text( + project.title or "", + size=fonts.BODY_1_SIZE, + color=colors.text_primary, + weight=fonts.BOLD_FONT if is_selected else None, + overflow="ellipsis", + max_lines=1, + expand=True, + ), + ], + ), + ), + Container( + width=200, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + _client_title, + size=fonts.BODY_2_SIZE, + color=colors.text_secondary, + overflow="ellipsis", + max_lines=1, + ), + ), + Container( + width=200, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + _contract_title, + size=fonts.BODY_2_SIZE, + color=colors.text_secondary, + overflow="ellipsis", + max_lines=1, + ), + ), + Container( + width=180, + clip_behavior=ClipBehavior.HARD_EDGE, + content=Text( + date_str, + size=fonts.BODY_2_SIZE, + color=colors.text_muted, + overflow="ellipsis", + max_lines=1, + ), ), - Container(height=1, bgcolor=colors.border_subtle), - *body_items, ], ), ) + self._is_selected = is_selected def _on_hover(self, e): - self.bgcolor = ( - colors.bg_surface_hovered if e.data == "true" else colors.bg_surface - ) + if self._is_selected: + return + self.bgcolor = colors.bg_surface_hovered if e.data == "true" else colors.bg self.update() -class ViewProjectScreen(views.EntityDetailScreen): - """View project screen""" +# ── Side panel ──────────────────────────────────────────────── - entity_name = "project" - edit_route = res_utils.PROJECT_EDITOR_SCREEN_ROUTE - def __init__(self, params: TViewParams, project_id: str): - super().__init__(params, project_id, ProjectsIntent()) +class ProjectSidePanel(views.EntitySidePanel): + """Right-side panel for viewing and editing projects.""" - def display_entity_data(self): - p = self.entity - has_contract = p.contract is not None - has_client = has_contract and p.contract.client is not None - - self.project_title_control.value = p.title - self.client_control.value = ( - p.contract.client.name if has_client else "Client not specified" - ) - self.contract_control.value = ( - p.contract.title if has_contract else "Contract not specified" + def __init__( + self, + on_close, + on_save, + on_delete, + intent: ProjectsIntent, + on_edit_requested=None, + ): + self.intent = intent + self._contracts_map: dict = {} + self._contract: Optional[Contract] = None + self._start_date = None + self._end_date = None + super().__init__( + on_close=on_close, + on_save=on_save, + on_delete=on_delete, + on_edit_requested=on_edit_requested, ) - self.update_field_rows(p) + + def _load_contracts(self): + self._contracts_map = self.intent.get_all_contracts_as_map() + + def _make_dropdown_item(self, cid, title): + return f"{cid}. {title}" + + def _get_contract_options(self): + return [ + self._make_dropdown_item(cid, c.title) + for cid, c in self._contracts_map.items() + ] + + # -- Detail view ---------------------------------------------------------- + + def build_detail_content(self, entity: Project) -> list: + p = entity _status = p.get_status(default="") + _status_color = { + "Active": colors.status_active, + "Upcoming": colors.status_upcoming, + "Completed": colors.status_completed, + }.get(_status, colors.text_muted) + + controls = [] + + # Status badge if _status: - self.project_status_control.value = f"Status {_status}" - self.project_status_control.visible = True - else: - self.project_status_control.visible = False - self.project_tagline_control.value = str(p.tag) if p.tag else "" - self.toggle_complete_status_btn.icon = ( - Icons.RADIO_BUTTON_CHECKED_OUTLINED - if p.is_completed - else Icons.RADIO_BUTTON_UNCHECKED_OUTLINED - ) - self.toggle_complete_status_btn.tooltip = ( - "Mark as incomplete" if p.is_completed else "Mark as complete" - ) + controls.append( + Container( + border_radius=dimens.RADIUS_PILL, + bgcolor=_status_color, + padding=Padding.symmetric( + horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XXS + ), + content=Text( + _status, + size=fonts.CAPTION_SIZE, + color=colors.text_inverse, + weight=fonts.BOLD_FONT, + ), + ) + ) - def on_view_client_clicked(self, e): - """opens the client view pop up when the client button is clicked""" - if not self.entity or not self.entity.client: - return - if self.popup_handler: - self.popup_handler.close_dialog() - self.popup_handler = ClientViewPopUp( - dialog_controller=self.dialog_controller, client=self.entity.client - ) - self.popup_handler.open_dialog() + # Tag + if p.tag: + controls.append( + Text( + p.tag, + size=fonts.BODY_2_SIZE, + color=colors.accent, + weight=fonts.BOLD_FONT, + ) + ) - def on_view_contract_clicked(self, e): - """redirects to the contract view screen when the contract button is clicked""" - if not self.entity or not self.entity.contract: - return - self.navigate_to_route( - res_utils.CONTRACT_DETAILS_SCREEN_ROUTE, self.entity.contract.id - ) + controls.append(self._get_section_divider()) - def build(self): - """Called when page is built""" - self.edit_project_btn = IconButton( - icon=Icons.EDIT_OUTLINED, - tooltip="Edit project", - on_click=self.on_edit_clicked, - icon_size=dimens.ICON_SIZE, + # Info fields + client_name = p.client.name if p.client else "Not specified" + contract_title = p.contract.title if p.contract else "Not specified" + controls.append( + self._get_detail_field("Client", client_name, Icons.PERSON_OUTLINE) ) - - self.toggle_complete_status_btn = IconButton( - icon=Icons.RADIO_BUTTON_UNCHECKED_OUTLINED, - icon_color=colors.accent, - tooltip="Mark as complete", - icon_size=dimens.ICON_SIZE, - on_click=self.on_toggle_complete_status, + controls.append( + self._get_detail_field( + "Contract", contract_title, Icons.DESCRIPTION_OUTLINED + ) ) - self.delete_project_btn = IconButton( - icon=Icons.DELETE_OUTLINE_ROUNDED, - icon_color=colors.danger, - tooltip="Delete project", - icon_size=dimens.ICON_SIZE, - on_click=self.on_delete_clicked, + controls.append(self._get_detail_field("Description", p.description or "")) + start = p.start_date.strftime("%d %b %Y") if p.start_date else "—" + end = p.end_date.strftime("%d %b %Y") if p.end_date else "—" + controls.append( + self._get_detail_field("Duration", f"{start} → {end}", Icons.DATE_RANGE) ) - self.project_title_control = views.THeading() - self.client_control = views.TSubHeading(color=colors.text_secondary) - self.contract_control = views.TSubHeading(color=colors.text_secondary) + controls.append(self._get_section_divider()) - self.project_status_control = views.TSubHeading( - size=fonts.BUTTON_SIZE, color=colors.accent - ) - self.project_tagline_control = views.TSubHeading( - size=fonts.BUTTON_SIZE, color=colors.accent + # Action buttons + controls.append( + self._get_action_bar( + views.TPrimaryButton( + label="Edit", + on_click=lambda e: self._switch_to_edit(), + icon=Icons.EDIT_OUTLINED, + ), + TextButton( + content=Text( + "Delete", + color=colors.danger, + size=fonts.BODY_2_SIZE, + ), + on_click=lambda e: self._on_delete_cb(entity) + if self._on_delete_cb + else None, + ), + ) ) + return controls - field_rows = self.build_field_rows( - [ - ("Description", "description"), - ("Start Date", "start_date"), - ("End Date", "end_date"), - ] - ) + def build_compact_detail(self, entity: Project) -> list: + p = entity + _status = p.get_status(default="") + _status_color = { + "Active": colors.status_active, + "Upcoming": colors.status_upcoming, + "Completed": colors.status_completed, + }.get(_status, colors.text_muted) - self.content = Row( - [ + top_row = [] + if _status: + top_row.append( Container( - padding=Padding.all(dimens.SPACE_STD), - width=int(dimens.MIN_WINDOW_WIDTH * 0.3), - content=Column( - controls=[ - IconButton( - icon=Icons.KEYBOARD_ARROW_LEFT, - on_click=self.navigate_back, - icon_size=dimens.ICON_SIZE, - ), - TextButton( - "Client", - tooltip="View project's client", - on_click=self.on_view_client_clicked, - ), - TextButton( - "Contract", - tooltip="View project's contract", - on_click=self.on_view_contract_clicked, - ), - ] + border_radius=dimens.RADIUS_PILL, + bgcolor=_status_color, + padding=Padding.symmetric( + horizontal=dimens.SPACE_SM, vertical=dimens.SPACE_XXS ), - ), - Container( - expand=True, - padding=Padding.all(dimens.SPACE_MD), - content=Column( - controls=[ - self.loading_indicator, - Row( - controls=[ - Icon( - Icons.WORK_ROUNDED, - size=dimens.ICON_SIZE, - ), - Column( - expand=True, - spacing=0, - run_spacing=0, - controls=[ - Row( - vertical_alignment=utils.CENTER_ALIGNMENT, - alignment=utils.SPACE_BETWEEN_ALIGNMENT, - controls=[ - views.THeading( - "Project", - size=fonts.HEADLINE_4_SIZE, - color=colors.accent, - ), - Row( - vertical_alignment=utils.CENTER_ALIGNMENT, - alignment=utils.SPACE_BETWEEN_ALIGNMENT, - spacing=dimens.SPACE_STD, - run_spacing=dimens.SPACE_STD, - controls=[ - self.edit_project_btn, - self.toggle_complete_status_btn, - self.delete_project_btn, - ], - ), - ], - ), - self.project_title_control, - self.client_control, - self.contract_control, - ], - ), - ], - ), - views.Spacer(md_space=True), - *field_rows, - views.Spacer(md_space=True), - Row( - spacing=dimens.SPACE_STD, - run_spacing=dimens.SPACE_STD, - alignment=utils.START_ALIGNMENT, - vertical_alignment=utils.CENTER_ALIGNMENT, - controls=[ - Card( - Container( - self.project_status_control, - padding=Padding.all(dimens.SPACE_SM), - ), - elevation=2, - ), - Card( - Container( - self.project_tagline_control, - padding=Padding.all(dimens.SPACE_SM), - ), - elevation=2, - ), - ], - ), - ], + content=Text( + _status, + size=fonts.CAPTION_SIZE, + color=colors.text_inverse, + weight=fonts.BOLD_FONT, ), + ) + ) + if p.tag: + top_row.append( + Text( + p.tag, + size=fonts.BODY_2_SIZE, + color=colors.accent, + weight=fonts.BOLD_FONT, + ) + ) + + detail_fields = [] + if p.description: + detail_fields.append( + self._compact_field("Description", p.description, col={"xs": 12}), + ) + + controls = [] + if top_row: + controls.append(Row(spacing=dimens.SPACE_SM, controls=top_row)) + if detail_fields: + controls.append(ResponsiveRow(controls=detail_fields)) + controls.append( + self._get_action_bar( + views.TPrimaryButton( + label="Edit", + on_click=lambda e: self._switch_to_edit(), + icon=Icons.EDIT_OUTLINED, ), - ], - spacing=dimens.SPACE_XS, - run_spacing=dimens.SPACE_MD, - alignment=utils.START_ALIGNMENT, - vertical_alignment=utils.START_ALIGNMENT, - expand=True, + TextButton( + content=Text("Delete", color=colors.danger, size=fonts.BODY_2_SIZE), + on_click=lambda e: self._on_delete_cb(entity) + if self._on_delete_cb + else None, + ), + ), + ) + return controls + + # -- Edit view ------------------------------------------------------------ + + def build_edit_content(self, entity: Optional[Project]) -> list: + self._load_contracts() + is_new = entity is None + + self._title_field = views.TTextField( + label="Title", + hint="A short, unique title", + initial_value=entity.title if entity else "", + ) + self._description_field = views.TMultilineField( + label="Description", + hint="A longer description", + minLines=2, + maxLines=3, + ) + if entity and entity.description: + self._description_field.value = entity.description + self._tag_field = views.TTextField( + label="Tag", + hint="#my-project", + initial_value=entity.tag if entity else "", ) + # Contract dropdown + self._contract = entity.contract if entity else None + contract_value = None + if self._contract: + contract_value = self._make_dropdown_item( + self._contract.id, self._contract.title + ) + self._contracts_field = views.TDropDown( + label="Contract", + on_change=self._on_contract_selected, + items=self._get_contract_options(), + ) + if contract_value: + self._contracts_field.update_value(contract_value) + + # Dates + self._start_date = entity.start_date if entity else None + self._end_date = entity.end_date if entity else None + self._start_date_field = views.DateSelector(label="Start Date") + self._end_date_field = views.DateSelector(label="End Date") + if self._start_date: + self._start_date_field.set_date(self._start_date) + if self._end_date: + self._end_date_field.set_date(self._end_date) + + save_label = "Create Project" if is_new else "Save Changes" + + # -- Compact multi-column layout -- + self._title_field.col = {"xs": 12, "sm": 8} + self._tag_field.col = {"xs": 12, "sm": 4} + self._contracts_field.col = {"xs": 12, "sm": 6} + self._start_date_field.col = {"xs": 6, "sm": 3} + self._end_date_field.col = {"xs": 6, "sm": 3} + self._description_field.col = {"xs": 12} + + return [ + ResponsiveRow( + controls=[self._title_field, self._tag_field], + spacing=dimens.SPACE_SM, + ), + ResponsiveRow( + controls=[ + self._contracts_field, + self._start_date_field, + self._end_date_field, + ], + spacing=dimens.SPACE_SM, + ), + ResponsiveRow( + controls=[self._description_field], + ), + self._edit_action_bar( + save_label, + on_save=lambda e: self._on_save_clicked(), + on_cancel=lambda e: self.close(), + ), + ] + + def _on_contract_selected(self, e): + sel = e.control.value + cid_str = sel.split(".")[0] + cid = int(cid_str) + if cid in self._contracts_map: + self._contract = self._contracts_map[cid] + + def _on_save_clicked(self): + """Validate and save.""" + if not self._title_field.value: + self._title_field.error = "Title is required" + self.update() + return + if not self._description_field.value: + self._description_field.error = "Description is required" + self.update() + return + + start = self._start_date_field.get_date() + end = self._end_date_field.get_date() + if not start or not end: + return + if start > end: + return + if not self._contract: + self._contracts_field.update_error_txt("Contract is required") + self.update() + return + if not self._tag_field.value: + self._tag_field.error = "Tag is required" + self.update() + return + + project = self._entity or Project() + project.title = self._title_field.value + project.description = self._description_field.value + project.start_date = start + project.end_date = end + project.tag = self._tag_field.value + project.contract = self._contract + + if self._on_save_cb: + self._on_save_cb(project) + class ProjectsListView(views.CrudListView): """View for displaying a list of projects""" @@ -388,18 +490,58 @@ def __init__(self, params): self.intent = ProjectsIntent() super().__init__(params) + def get_side_panel(self): + return ProjectSidePanel( + on_close=self._on_panel_closed, + on_save=self._on_project_saved, + on_delete=self.on_delete_clicked, + intent=self.intent, + on_edit_requested=self._on_inline_edit_requested, + ) + + def _on_project_saved(self, project): + """Save project via intent, close panel, refresh list.""" + result = self.intent.save_project(project) + if result.was_intent_successful: + self.show_snack("Project saved!") + self._side_panel.close() + self.reload_all_data() + else: + self.show_snack(result.error_msg, is_error=True) + + def get_column_headers(self): + return [ + ("Project", None), + ("Client", 200), + ("Contract", 200), + ("Dates", 180), + ] + def make_card(self, project): - return ProjectCard( + is_selected = self._selected_entity_id == project.id + return ProjectRow( project=project, - on_view_details_clicked=lambda pid: self.navigate_to_route( - res_utils.PROJECT_DETAILS_SCREEN_ROUTE, pid - ), + on_click=lambda pid: self._open_detail(pid), on_delete_clicked=self._on_delete_by_id, - on_edit_clicked=lambda pid: self.navigate_to_route( - res_utils.PROJECT_EDITOR_SCREEN_ROUTE, pid - ), + on_edit_clicked=lambda pid: self._open_editor(pid), + is_selected=is_selected, ) + def _open_detail(self, project_id): + if project_id in self.items_to_display: + self.open_detail_panel(self.items_to_display[project_id]) + + def _open_editor(self, project_id): + if project_id in self.items_to_display: + self.open_edit_panel(self.items_to_display[project_id]) + + def parent_intent_listener(self, intent: str, data=None): + if intent == res_utils.RELOAD_INTENT: + self.reload_all_data() + elif intent == res_utils.PROJECT_EDITOR_SCREEN_ROUTE: + # "+ New" button opens editor panel for new project + self.open_edit_panel(None) + def _on_delete_by_id(self, project_id): if project_id in self.items_to_display: self.on_delete_clicked(self.items_to_display[project_id]) diff --git a/tuttle/app/res/colors.py b/tuttle/app/res/colors.py index 20669cf..668010a 100644 --- a/tuttle/app/res/colors.py +++ b/tuttle/app/res/colors.py @@ -1,24 +1,25 @@ """Design tokens — semantic colors for Tuttle's dark theme. -Inspired by macOS dark-mode conventions and VS Code's editor palette. -All entity views, components, and the app shell should reference these -tokens instead of hard-coding hex values. +Based on Apple Human Interface Guidelines dark-mode system colors +and macOS Ventura's native app palette. """ # ── Backgrounds ────────────────────────────────────────────── -bg = "#1E1E1E" # main window / page background -bg_sidebar = "#252526" # sidebar panel -bg_surface = "#2D2D2D" # cards, panels, inputs -bg_surface_hovered = "#383838" # hovered cards / elevated surfaces -bg_titlebar = "#1E1E1E" # title bar (matches bg for seamless look) -bg_statusbar = "#007ACC" # VS Code-style status bar accent -bg_toolbar = "#252526" # action bar / toolbar -bg_input = "#3C3C3C" # text field fill +bg = "#1C1C1E" # main window / page background (Apple systemBackground) +bg_sidebar = "#2C2C2E" # sidebar panel (Apple secondarySystemBackground) +bg_surface = "#3A3A3C" # cards, panels (Apple systemGray4) +bg_surface_hovered = "#48484A" # hovered cards (Apple systemGray3) +bg_titlebar = "#1C1C1E" # title bar (seamless with bg) +bg_statusbar = "#007ACC" # status bar accent +bg_statusbar_warning = "#CC7700" # status bar with warnings +bg_statusbar_danger = "#CC3333" # status bar with overdue items +bg_toolbar = "#1C1C1E" # toolbar merges into background +bg_input = "#3A3A3C" # text field fill # ── Text ───────────────────────────────────────────────────── -text_primary = "#CCCCCC" # main text -text_secondary = "#9D9D9D" # secondary / subtitle text -text_muted = "#6D6D6D" # labels, section headers, placeholders +text_primary = "#E5E5E7" # main text (Apple label, dark) +text_secondary = "#AEAEB2" # secondary text (Apple secondaryLabel) +text_muted = "#8E8E93" # labels, placeholders (Apple systemGray) text_inverse = "#FFFFFF" # text on accent backgrounds # ── Accent ─────────────────────────────────────────────────── @@ -31,14 +32,21 @@ success = "#30D158" # success indicators (macOS system green) warning = "#FFD60A" # warnings (macOS system yellow) +# ── Status colors ──────────────────────────────────────────── +status_active = "#30D158" # active projects/contracts +status_upcoming = "#0A84FF" # upcoming / scheduled +status_completed = "#8E8E93" # completed / archived +status_overdue = "#FF453A" # overdue invoices +status_draft = "#636366" # draft / not yet sent + # ── Borders & separators ───────────────────────────────────── -border = "#3C3C3C" # card borders, dividers -border_subtle = "#2D2D2D" # very subtle separators -separator = "#1a1a1a" # hairline dividers +border = "#38383A" # card borders, dividers +border_subtle = "#2C2C2E" # very subtle separators +separator = "#1C1C1E" # hairline dividers # ── Activity bar ───────────────────────────────────────────── activity_bar_bg = "#333333" # activity bar background -activity_bar_icon = "#858585" # inactive icon +activity_bar_icon = "#8E8E93" # inactive icon activity_bar_icon_active = "#FFFFFF" # active icon activity_bar_indicator = "#FFFFFF" # active indicator bar diff --git a/tuttle/app/res/dimens.py b/tuttle/app/res/dimens.py index 183cfce..da9f2bd 100644 --- a/tuttle/app/res/dimens.py +++ b/tuttle/app/res/dimens.py @@ -18,9 +18,9 @@ SPACE_XXL = 48 # ── Layout chrome ──────────────────────────────────────────── -TOOLBAR_HEIGHT = 44 -FOOTER_HEIGHT = 22 # VS Code-style status bar -SIDEBAR_WIDTH = 220 +TOOLBAR_HEIGHT = 48 +FOOTER_HEIGHT = 26 # status bar — slightly taller for interactive widgets +SIDEBAR_WIDTH = 240 # wider for data tree SIDEBAR_COLLAPSED_WIDTH = 48 ACTIVITY_BAR_WIDTH = 48 TITLEBAR_HEIGHT = 38 @@ -30,6 +30,7 @@ RADIUS_MD = 6 RADIUS_LG = 8 RADIUS_XL = 12 +RADIUS_2XL = 16 RADIUS_PILL = 999 # ── Icon sizes ─────────────────────────────────────────────── @@ -43,6 +44,11 @@ CLICKABLE_STD_HEIGHT = 36 # ── Cards ──────────────────────────────────────────────────── -CARD_MAX_EXTENT = 400 -CARD_SPACING = 16 -CARD_BORDER_WIDTH = 1 +CARD_MAX_EXTENT = 420 +CARD_SPACING = 20 # more breathing room +CARD_BORDER_WIDTH = 0 # borderless cards — rely on bg contrast + +# ── Status bar items ───────────────────────────────────────── +STATUSBAR_ITEM_PADDING_H = 10 +STATUSBAR_ITEM_SPACING = 6 +STATUSBAR_DIVIDER_WIDTH = 1 diff --git a/tuttle/app/res/fonts.py b/tuttle/app/res/fonts.py index 8066d9d..d6920f6 100644 --- a/tuttle/app/res/fonts.py +++ b/tuttle/app/res/fonts.py @@ -1,8 +1,11 @@ """Defines font related constants used in app. -Sizes bumped for readability (matching VS Code / macOS standards). +Sizes tuned for macOS native feel — slightly larger body text +for readability, semi-bold headings for clear hierarchy. """ +from flet import FontWeight + DEFAULT_FONT = "body" HEADLINE_FONT = "headline" MONOSPACE_FONT = "monospace" @@ -15,20 +18,20 @@ # ── Font sizes ─────────────────────────────────────────────── -HEADLING_1_SIZE = 24 # page titles -HEADLINE_2_SIZE = 20 # section titles -HEADLINE_3_SIZE = 17 # sub-section titles +HEADLINE_0_SIZE = 32 # hero / splash titles +HEADLING_1_SIZE = 28 # page titles +HEADLINE_2_SIZE = 22 # section titles +HEADLINE_3_SIZE = 18 # sub-section titles HEADLINE_4_SIZE = 15 # card titles, toolbar headings -BODY_1_SIZE = 13 # primary body text -BODY_2_SIZE = 12 # secondary body text -SUBTITLE_1_SIZE = 14 # emphasized labels +BODY_1_SIZE = 14 # primary body text (up from 13) +BODY_2_SIZE = 13 # secondary body text (up from 12) +SUBTITLE_1_SIZE = 15 # emphasized labels SUBTITLE_2_SIZE = 13 # secondary labels BUTTON_SIZE = 13 # button text OVERLINE_SIZE = 11 # overline / section headers CAPTION_SIZE = 11 # captions, helper text +STATUS_BAR_SIZE = 12 # status bar text # ── Font weights ───────────────────────────────────────────── -from flet import FontWeight - -BOLD_FONT = FontWeight.W_500 +BOLD_FONT = FontWeight.W_600 # semi-bold for crisper hierarchy BOLDER_FONT = FontWeight.BOLD diff --git a/tuttle/app/res/theme.py b/tuttle/app/res/theme.py index 9148b53..4a9aff0 100644 --- a/tuttle/app/res/theme.py +++ b/tuttle/app/res/theme.py @@ -34,5 +34,5 @@ def get_theme_mode_from_value(value: str): ), use_material3=True, font_family=DEFAULT_FONT, - visual_density=VisualDensity.COMPACT, + visual_density=VisualDensity.STANDARD, ) diff --git a/tuttle/app/timetracking/view.py b/tuttle/app/timetracking/view.py index b8de4b7..3312e51 100644 --- a/tuttle/app/timetracking/view.py +++ b/tuttle/app/timetracking/view.py @@ -453,7 +453,7 @@ def build(self): col={"xs": 12}, controls=[ views.THeading( - title="Time Tracking", size=fonts.HEADLINE_4_SIZE + title="Time Tracking", size=fonts.HEADLINE_2_SIZE ), self.loading_indicator, self.ongoing_action_hint, diff --git a/tuttle_tests/test_app_start.py b/tuttle_tests/test_app_start.py index 27b3d14..20faa3c 100644 --- a/tuttle_tests/test_app_start.py +++ b/tuttle_tests/test_app_start.py @@ -3,7 +3,8 @@ Verifies that: - all critical modules can be imported (catches dependency issues), - the database schema can be materialised in memory, -- the app process starts without an immediate crash. +- the app process starts without an immediate crash, +- UI panel constructors and content-building methods run without error. """ import importlib @@ -48,6 +49,8 @@ "tuttle.app.home.view", "tuttle.app.projects.view", "tuttle.app.contracts.view", + "tuttle.app.clients.view", + "tuttle.app.contacts.view", "tuttle.app.invoicing.view", "tuttle.app.timetracking.view", "tuttle.app.preferences.view", @@ -68,6 +71,118 @@ def test_import_app_module(module_name): assert mod is not None +# --------------------------------------------------------------------------- +# 1b. Resource-module attribute smoke tests +# --------------------------------------------------------------------------- +# These catch typos like ``fonts.BODY_FONT`` when only ``fonts.BOLD_FONT`` +# exists. Each tuple is (module_path, [expected_attributes]). + +_RES_ATTRS = [ + ( + "tuttle.app.res.fonts", + [ + "DEFAULT_FONT", + "HEADLINE_FONT", + "APP_FONTS", + "HEADLINE_0_SIZE", + "HEADLING_1_SIZE", + "HEADLINE_2_SIZE", + "HEADLINE_3_SIZE", + "HEADLINE_4_SIZE", + "BODY_1_SIZE", + "BODY_2_SIZE", + "SUBTITLE_1_SIZE", + "SUBTITLE_2_SIZE", + "BUTTON_SIZE", + "OVERLINE_SIZE", + "CAPTION_SIZE", + "STATUS_BAR_SIZE", + "BOLD_FONT", + "BOLDER_FONT", + ], + ), + ( + "tuttle.app.res.colors", + [ + "bg", + "bg_surface", + "bg_surface_hovered", + "text_primary", + "text_secondary", + "text_muted", + "accent", + "accent_muted", + "border", + "status_active", + "status_upcoming", + "status_completed", + ], + ), + ( + "tuttle.app.res.dimens", + [ + "SPACE_XS", + "SPACE_SM", + "SPACE_MD", + "SPACE_LG", + "RADIUS_PILL", + "RADIUS_XL", + ], + ), +] + + +@pytest.mark.parametrize( + "module_name,attr", + [(mod, attr) for mod, attrs in _RES_ATTRS for attr in attrs], + ids=lambda val: val if isinstance(val, str) else "", +) +def test_res_module_attribute_exists(module_name, attr): + """Every resource constant used by the UI must actually be defined.""" + mod = importlib.import_module(module_name) + assert hasattr(mod, attr), f"module {module_name!r} has no attribute {attr!r}" + + +# --------------------------------------------------------------------------- +# 1c. View-class instantiation guards +# --------------------------------------------------------------------------- +# Verify that the key classes/functions referenced in each view module are +# actually present — catches stale renames (e.g. ProjectCard → ProjectRow). + +_VIEW_EXPORTS = [ + ( + "tuttle.app.projects.view", + ["ProjectRow", "ProjectsListView", "ProjectSidePanel"], + ), + ( + "tuttle.app.contracts.view", + ["ContractRow", "ContractsListView", "ContractSidePanel"], + ), + ("tuttle.app.clients.view", ["ClientRow", "ClientsListView", "ClientSidePanel"]), + ( + "tuttle.app.contacts.view", + ["ContactRow", "ContactsListView", "ContactSidePanel"], + ), + ( + "tuttle.app.core.views", + ["CrudListView", "EntitySidePanel", "TTextField", "TBodyText"], + ), +] + + +@pytest.mark.parametrize( + "module_name,attr", + [(mod, attr) for mod, attrs in _VIEW_EXPORTS for attr in attrs], + ids=lambda val: val if isinstance(val, str) else "", +) +def test_view_module_exports(module_name, attr): + """Key view classes must exist and be importable.""" + mod = importlib.import_module(module_name) + assert hasattr( + mod, attr + ), f"module {module_name!r} missing expected export {attr!r}" + + # --------------------------------------------------------------------------- # 2. Database schema creation # --------------------------------------------------------------------------- @@ -127,3 +242,347 @@ def test_app_process_starts(): pgid = os.getpgid(proc.pid) os.killpg(pgid, signal.SIGKILL) proc.wait(timeout=5) + + +# --------------------------------------------------------------------------- +# 4. Side-panel runtime exercise tests +# --------------------------------------------------------------------------- +# These go beyond import checks: they construct real panels with demo data +# and call every content-building method. This catches runtime errors like +# missing attributes (e.g. TDropDown.drop_down before build()) or wrong +# constructor signatures (e.g. unexpected keyword argument). + +import datetime +from decimal import Decimal + +from tuttle.model import ( + Address, + Client, + Contact, + Contract, + Cycle, + Project, + TimeUnit, +) + + +@pytest.fixture +def demo_contact(): + """A realistic Contact with address.""" + return Contact( + id=1, + first_name="Ada", + last_name="Lovelace", + email="ada@example.com", + company="Babbage Ltd.", + address=Address( + id=1, + street="Dorset", + number="42", + city="London", + postal_code="W1A 1AB", + country="UK", + ), + ) + + +@pytest.fixture +def demo_client(demo_contact): + """A realistic Client with invoicing contact.""" + return Client( + id=1, + name="Babbage Ltd.", + invoicing_contact=demo_contact, + ) + + +@pytest.fixture +def demo_contract(demo_client): + """A realistic Contract with all fields populated.""" + return Contract( + id=1, + title="Analytical Engine Maintenance", + client=demo_client, + signature_date=datetime.date(2025, 1, 15), + start_date=datetime.date(2025, 2, 1), + end_date=datetime.date(2026, 6, 1), + rate=800, + currency="EUR", + VAT_rate=Decimal("0.19"), + unit=TimeUnit.day, + units_per_workday=8, + volume=200, + term_of_payment=14, + billing_cycle=Cycle.monthly, + ) + + +@pytest.fixture +def demo_project(demo_contract): + """A realistic Project linked to a contract.""" + return Project( + id=1, + title="Engine Refactoring", + tag="#engine-refactoring", + description="Refactor the analytical engine for better performance.", + is_completed=False, + start_date=datetime.date(2025, 2, 1), + end_date=datetime.date(2026, 6, 1), + contract=demo_contract, + ) + + +def _noop(*args, **kwargs): + """No-op callback for panel constructors.""" + pass + + +class TestProjectSidePanel: + """Exercise ProjectSidePanel construction and content building.""" + + def _make_panel(self): + from tuttle.app.projects.view import ProjectSidePanel + + return ProjectSidePanel( + on_close=_noop, + on_save=_noop, + on_delete=_noop, + intent=None, + on_edit_requested=_noop, + ) + + def test_constructor(self): + panel = self._make_panel() + assert panel is not None + + def test_build_detail_content(self, demo_project): + panel = self._make_panel() + controls = panel.build_detail_content(demo_project) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_compact_detail(self, demo_project): + panel = self._make_panel() + controls = panel.build_compact_detail(demo_project) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_compact_detail_no_description(self, demo_contract): + """Project with no description should still render.""" + from tuttle.app.projects.view import ProjectSidePanel + + panel = self._make_panel() + proj = Project( + id=99, + title="Bare project", + tag="#bare", + description="", + is_completed=True, + start_date=datetime.date.today(), + end_date=datetime.date.today(), + contract=demo_contract, + ) + controls = panel.build_compact_detail(proj) + assert isinstance(controls, list) + + def test_build_edit_content(self, demo_project): + """build_edit_content must run without AttributeError. + + This specifically guards against bugs like TDropDown.drop_down + not existing before Flet's build() lifecycle. + """ + panel = self._make_panel() + panel._contracts_map = {} + panel._load_contracts = lambda: None # stub — no intent in tests + controls = panel.build_edit_content(demo_project) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_edit_content_new(self): + """Creating a new project (entity=None) must also work.""" + panel = self._make_panel() + panel._contracts_map = {} + panel._load_contracts = lambda: None + controls = panel.build_edit_content(None) + assert isinstance(controls, list) + + +class TestContractSidePanel: + """Exercise ContractSidePanel construction and content building.""" + + def _make_panel(self): + from tuttle.app.contracts.view import ContractSidePanel + + return ContractSidePanel( + on_close=_noop, + on_save=_noop, + on_delete=_noop, + intent=None, + client_storage=None, + on_edit_requested=_noop, + ) + + def test_constructor(self): + panel = self._make_panel() + assert panel is not None + + def test_build_detail_content(self, demo_contract): + panel = self._make_panel() + controls = panel.build_detail_content(demo_contract) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_compact_detail(self, demo_contract): + panel = self._make_panel() + controls = panel.build_compact_detail(demo_contract) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_edit_content(self, demo_contract): + panel = self._make_panel() + panel._clients_map = {} + panel._contacts_map = {} + panel._currencies = ["EUR", "USD"] + panel._client_storage = None + panel._load_data = lambda: None # stub — no intent in tests + controls = panel.build_edit_content(demo_contract) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_edit_content_new(self): + panel = self._make_panel() + panel._clients_map = {} + panel._contacts_map = {} + panel._currencies = ["EUR", "USD"] + panel._client_storage = None + panel._load_data = lambda: None + controls = panel.build_edit_content(None) + assert isinstance(controls, list) + + +class TestClientSidePanel: + """Exercise ClientSidePanel construction and content building.""" + + def _make_panel(self): + from tuttle.app.clients.view import ClientSidePanel + + return ClientSidePanel( + on_close=_noop, + on_save=_noop, + on_delete=_noop, + intent=None, + on_edit_requested=_noop, + ) + + def test_constructor(self): + panel = self._make_panel() + assert panel is not None + + def test_build_detail_content(self, demo_client): + panel = self._make_panel() + controls = panel.build_detail_content(demo_client) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_compact_detail(self, demo_client): + panel = self._make_panel() + controls = panel.build_compact_detail(demo_client) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_edit_content(self, demo_client): + panel = self._make_panel() + panel._contacts_map = {} + panel._load_contacts = lambda: None # stub — no intent in tests + controls = panel.build_edit_content(demo_client) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_edit_content_new(self): + panel = self._make_panel() + panel._contacts_map = {} + panel._load_contacts = lambda: None + controls = panel.build_edit_content(None) + assert isinstance(controls, list) + + +class TestContactSidePanel: + """Exercise ContactSidePanel construction and content building.""" + + def _make_panel(self): + from tuttle.app.contacts.view import ContactSidePanel + + return ContactSidePanel( + on_close=_noop, + on_save=_noop, + on_delete=_noop, + intent=None, + on_edit_requested=_noop, + ) + + def test_constructor(self): + panel = self._make_panel() + assert panel is not None + + def test_build_detail_content(self, demo_contact): + panel = self._make_panel() + controls = panel.build_detail_content(demo_contact) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_compact_detail(self, demo_contact): + panel = self._make_panel() + controls = panel.build_compact_detail(demo_contact) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_edit_content(self, demo_contact): + panel = self._make_panel() + controls = panel.build_edit_content(demo_contact) + assert isinstance(controls, list) + assert len(controls) > 0 + + def test_build_edit_content_new(self): + panel = self._make_panel() + controls = panel.build_edit_content(None) + assert isinstance(controls, list) + + +# --------------------------------------------------------------------------- +# 5. TDropDown pre-build access tests +# --------------------------------------------------------------------------- +# Guards against the pattern where TDropDown.drop_down is created lazily in +# build() but accessed via update_value() before the control is rendered. + + +class TestTDropDown: + """Verify TDropDown methods work before Flet's build() lifecycle.""" + + def test_value_accessible_before_build(self): + from tuttle.app.core.views import TDropDown + + dd = TDropDown(label="Test", items=["a", "b", "c"]) + # Must not raise AttributeError + assert dd.drop_down is not None + + def test_update_value_before_build(self): + from tuttle.app.core.views import TDropDown + + dd = TDropDown(label="Test", items=["a", "b", "c"]) + dd.update_value("b") + assert dd.value == "b" + + def test_initial_value(self): + from tuttle.app.core.views import TDropDown + + dd = TDropDown(label="Test", items=["x", "y"], initial_value="y") + assert dd.value == "y" + + def test_update_dropdown_items_before_build(self): + """update_dropdown_items must not crash before build().""" + from tuttle.app.core.views import TDropDown + + dd = TDropDown(label="Test", items=["a"]) + # This would have crashed with the old code + # (drop_down only created in build()) + dd.drop_down.options # access must work From ecf9a4e495f6163747ffda24574724a43405081d Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Tue, 10 Mar 2026 08:24:02 +0100 Subject: [PATCH 02/11] feat: enhance snack dismiss behavior and improve route view management --- app.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app.py b/app.py index c985482..8aba4e7 100644 --- a/app.py +++ b/app.py @@ -178,6 +178,12 @@ def show_snack( action=action, open=True, ) + + def on_snack_dismiss(e): + if snack in self.page.overlay: + self.page.overlay.remove(snack) + + snack.on_dismiss = on_snack_dismiss self.page.show_dialog(snack) def control_alert_dialog( @@ -232,6 +238,12 @@ def on_route_change(self, e=None): if current_route in self.route_to_route_view_cache: # route already visited: reuse cached view self.current_route_view = self.route_to_route_view_cache[current_route] + if not self.current_route_view.keep_back_stack: + self.route_to_route_view_cache.clear() + self.route_to_route_view_cache[current_route] = self.current_route_view + self.page.views.clear() + if self.current_route_view.view not in self.page.views: + self.page.views.append(self.current_route_view.view) self.page.update() self.current_route_view.on_window_resized( self.page.window.width, self.page.window.height From 0c8d610bff16453db4203fe251816223497605b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:34:41 +0000 Subject: [PATCH 03/11] Initial plan From 7d72fc958add5cf62bf172d6cf563513e23033b5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 07:36:43 +0000 Subject: [PATCH 04/11] Use TextOverflow.ELLIPSIS enum instead of string in projects/view.py Co-authored-by: clstaudt <875194+clstaudt@users.noreply.github.com> --- tuttle/app/projects/view.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tuttle/app/projects/view.py b/tuttle/app/projects/view.py index a2b371a..816da81 100644 --- a/tuttle/app/projects/view.py +++ b/tuttle/app/projects/view.py @@ -18,6 +18,7 @@ Row, Text, TextButton, + TextOverflow, Control, Alignment, Border, @@ -102,7 +103,7 @@ def __init__( size=fonts.BODY_1_SIZE, color=colors.text_primary, weight=fonts.BOLD_FONT if is_selected else None, - overflow="ellipsis", + overflow=TextOverflow.ELLIPSIS, max_lines=1, expand=True, ), @@ -116,7 +117,7 @@ def __init__( _client_title, size=fonts.BODY_2_SIZE, color=colors.text_secondary, - overflow="ellipsis", + overflow=TextOverflow.ELLIPSIS, max_lines=1, ), ), @@ -127,7 +128,7 @@ def __init__( _contract_title, size=fonts.BODY_2_SIZE, color=colors.text_secondary, - overflow="ellipsis", + overflow=TextOverflow.ELLIPSIS, max_lines=1, ), ), @@ -138,7 +139,7 @@ def __init__( date_str, size=fonts.BODY_2_SIZE, color=colors.text_muted, - overflow="ellipsis", + overflow=TextOverflow.ELLIPSIS, max_lines=1, ), ), From b48e79125188d984dd91317d5de8379e84eac2f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 08:59:41 +0000 Subject: [PATCH 05/11] Initial plan From d8c2d4b530b74d70eee90fc1b95674584aa2a703 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:01:24 +0000 Subject: [PATCH 06/11] Initial plan From 9a2407d5492bda7d5c52cdf7c0aab96bf60002d9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:01:40 +0000 Subject: [PATCH 07/11] Initial plan From 328fcce409e7d18c737108afbbed0c82cc9ca173 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:02:07 +0000 Subject: [PATCH 08/11] Guard StatusBarManager against uninitialized self.bar Co-authored-by: clstaudt <875194+clstaudt@users.noreply.github.com> --- tuttle/app/core/status_bar.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/tuttle/app/core/status_bar.py b/tuttle/app/core/status_bar.py index 4b6c69b..0393bba 100644 --- a/tuttle/app/core/status_bar.py +++ b/tuttle/app/core/status_bar.py @@ -186,6 +186,9 @@ def __init__( self._right_divider = StatusBarDivider() self._right_divider.visible = False + # Initialized to None; set by build() + self.bar: Optional[Container] = None + def build(self) -> Container: """Build the status bar container.""" self.bar = Container( @@ -281,15 +284,18 @@ def update_warnings( self._warning_divider.visible = has_warnings # Update bar color based on urgency - if overdue_count > 0: - self.bar.bgcolor = colors.bg_statusbar_danger - elif has_warnings: - self.bar.bgcolor = colors.bg_statusbar_warning - else: - self.bar.bgcolor = colors.bg_statusbar + if self.bar is not None: + if overdue_count > 0: + self.bar.bgcolor = colors.bg_statusbar_danger + elif has_warnings: + self.bar.bgcolor = colors.bg_statusbar_warning + else: + self.bar.bgcolor = colors.bg_statusbar def try_update(self): """Try to push visual updates to the bar.""" + if self.bar is None: + return try: self.bar.update() except Exception: From 3c63df4590a0c4c0590e2ac689def19ac95239ae Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:04:04 +0000 Subject: [PATCH 09/11] Restore self.update() in DateSelector.set_date() to keep UI in sync Co-authored-by: clstaudt <875194+clstaudt@users.noreply.github.com> --- tuttle/app/core/views.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tuttle/app/core/views.py b/tuttle/app/core/views.py index 65aa312..8d4b9b1 100644 --- a/tuttle/app/core/views.py +++ b/tuttle/app/core/views.py @@ -571,6 +571,7 @@ def set_date(self, date: Optional[datetime.date] = None): self._picker.value = datetime.datetime.combine(date, datetime.time()) self._date_text.value = date.strftime(self._DATE_FMT) self._date_text.color = colors.text_primary + self.update() def get_date(self) -> Optional[datetime.date]: return self._selected_date From 5319a884a099679fd1442ff71b092bdc9bd35bb2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 10 Mar 2026 09:07:32 +0000 Subject: [PATCH 10/11] Fix TDropDown.update_value() to call self.update() for UI refresh when mounted Co-authored-by: clstaudt <875194+clstaudt@users.noreply.github.com> --- tuttle/app/core/views.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tuttle/app/core/views.py b/tuttle/app/core/views.py index 65aa312..8c8fefb 100644 --- a/tuttle/app/core/views.py +++ b/tuttle/app/core/views.py @@ -448,6 +448,10 @@ def update_dropdown_items(self, items: List[str]): def update_value(self, new_value: str): self.drop_down.value = new_value self.drop_down.error_text = None + try: + self.update() + except RuntimeError: + pass # control not yet mounted @property def value(self): From 086401be26da05c7cd4b46c8c5650fe7791c470b Mon Sep 17 00:00:00 2001 From: Christian Staudt Date: Thu, 12 Mar 2026 22:27:19 +0100 Subject: [PATCH 11/11] Handle RuntimeError in DateSelector.set_date() during update call --- tuttle/app/core/views.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tuttle/app/core/views.py b/tuttle/app/core/views.py index a43845c..f6c2c62 100644 --- a/tuttle/app/core/views.py +++ b/tuttle/app/core/views.py @@ -575,7 +575,10 @@ def set_date(self, date: Optional[datetime.date] = None): self._picker.value = datetime.datetime.combine(date, datetime.time()) self._date_text.value = date.strftime(self._DATE_FMT) self._date_text.color = colors.text_primary - self.update() + try: + self.update() + except RuntimeError: + pass # control not yet attached to a page def get_date(self) -> Optional[datetime.date]: return self._selected_date