From 0f86ee14ac101a0215e805862c803660776adc51 Mon Sep 17 00:00:00 2001 From: joe Date: Tue, 24 Feb 2026 10:10:16 +0900 Subject: [PATCH] =?UTF-8?q?mini=E6=B1=9A=E6=9F=93=E3=82=92=E4=BF=AE?= =?UTF-8?q?=E5=BE=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/editor_framework.py | 45 ++- main.py | 700 ++++++++++++++++++++++++++++----- 2 files changed, 625 insertions(+), 120 deletions(-) diff --git a/components/editor_framework.py b/components/editor_framework.py index 0f09a53..78998ad 100644 --- a/components/editor_framework.py +++ b/components/editor_framework.py @@ -117,7 +117,7 @@ def build_invoice_items_edit_table( """編集モードの明細テーブル。""" header_row = ft.Row( [ - ft.Container(width=28), + ft.Container(width=32), ft.Text("商品", size=12, weight=ft.FontWeight.BOLD, width=180), ft.Text("数", size=12, weight=ft.FontWeight.BOLD, width=35), ft.Text("単価", size=12, weight=ft.FontWeight.BOLD, width=70), @@ -193,21 +193,35 @@ def build_invoice_items_edit_table( "subtotal": subtotal_text, } - handle = ft.Icon( - ft.Icons.DRAG_HANDLE, - size=18, - color=ft.Colors.BLUE_GREY_400, - visible=enable_reorder and not is_locked, - ) + if enable_reorder and on_reorder and not is_locked: + up_button = ft.IconButton( + ft.Icons.ARROW_UPWARD, + icon_size=16, + tooltip="上へ", + disabled=i == 0, + on_click=(lambda _, idx=i: on_reorder(idx, idx - 1)) if i > 0 else None, + ) + down_button = ft.IconButton( + ft.Icons.ARROW_DOWNWARD, + icon_size=16, + tooltip="下へ", + disabled=i == len(items) - 1, + on_click=(lambda _, idx=i: on_reorder(idx, idx + 1)) if i < len(items) - 1 else None, + ) + reorder_buttons = ft.Column([up_button, down_button], spacing=0, width=32) + delete_slot = ft.Container(width=0) + else: + reorder_buttons = ft.Container(width=0) + delete_slot = delete_button row_control = ft.Row( [ - handle, + reorder_buttons, product_field, quantity_field, unit_price_field, subtotal_text, - delete_button, + delete_slot, ], vertical_alignment=ft.CrossAxisAlignment.CENTER, key=f"row-{i}-{item.description}", @@ -215,18 +229,7 @@ def build_invoice_items_edit_table( data_rows.append(row_control) - if enable_reorder and on_reorder and not is_locked: - reorder_items = [ - ft.ReorderableListViewItem(key=str(idx), content=row) - for idx, row in enumerate(data_rows) - ] - list_control = ft.ReorderableListView( - controls=reorder_items, - on_reorder=lambda e: on_reorder(e.old_index, e.new_index), - shrink_wrap=True, - ) - else: - list_control = ft.Column(data_rows, spacing=4) + list_control = ft.Column(data_rows, spacing=4) return ft.Column( [ diff --git a/main.py b/main.py index 7682ef8..2822eee 100644 --- a/main.py +++ b/main.py @@ -10,8 +10,8 @@ import logging import asyncio import threading import sqlite3 -from datetime import datetime -from typing import List, Dict, Optional +from datetime import datetime, date, time +from typing import List, Dict, Optional, Any from models.invoice_models import DocumentType, Invoice, create_sample_invoices, Customer, InvoiceItem, Product from components.customer_picker import CustomerPickerModal from components.explorer_framework import ( @@ -75,7 +75,7 @@ class AppBar(ft.Container): def __init__(self, title: str, show_back: bool = False, show_edit: bool = False, on_back=None, on_edit=None, page=None, action_icon=None, action_tooltip: str = "編集", bottom: Optional[ft.Control] = None, trailing_controls: Optional[list] = None, - title_control: Optional[ft.Control] = None): + title_control: Optional[ft.Control] = None, leading_control: Optional[ft.Control] = None): super().__init__() self.title = title self.show_back = show_back @@ -88,6 +88,7 @@ class AppBar(ft.Container): self.bottom = bottom self.trailing_controls = trailing_controls or [] self.title_control = title_control + self.leading_control = leading_control self.bgcolor = ft.Colors.BLUE_GREY_50 self.padding = ft.Padding.symmetric(horizontal=16, vertical=8) @@ -108,6 +109,8 @@ class AppBar(ft.Container): on_click=self.on_back if self.on_back else None ) ) + elif self.leading_control is not None: + controls.append(self.leading_control) else: controls.append(ft.Container(width=48)) # スペーーサー @@ -213,10 +216,41 @@ class FlutterStyleDashboard: self.app_service = AppService() self.invoices = [] self.customers = [] + self.settings_state = { + "theme": self.current_theme, + "stay_on_detail_after_save": self.stay_on_detail_after_save, + "company_name": "", + "company_kana": "", + "company_address": "", + "company_phone": "", + "company_representative": "", + "company_email": "", + "smtp_host": "", + "smtp_port": "", + "smtp_username": "", + "smtp_password": "", + "backup_path": "", + "corner_stamp_path": "", + "rep_stamp_path": "", + "bank_accounts": [{"active": False} for _ in range(4)], + } self._item_row_refs: Dict[int, Dict[str, ft.Control]] = {} self._total_amount_text: Optional[ft.Text] = None self._tax_amount_text: Optional[ft.Text] = None self._subtotal_text: Optional[ft.Text] = None + self.is_reorder_mode = False + self.is_company_settings_open = False + self._pending_stamp_target: Optional[str] = None + self._stamp_picker: Optional[ft.FilePicker] = None + self.max_active_bank_accounts = 2 + self.edit_button_style = ft.ButtonStyle( + bgcolor=ft.Colors.WHITE, + color=ft.Colors.BLUE_GREY_800, + padding=ft.Padding.symmetric(horizontal=12, vertical=8), + shape=ft.RoundedRectangleBorder(radius=6), + side=ft.BorderSide(1, ft.Colors.BLUE_200), + overlay_color=ft.Colors.BLUE_50, + ) self.setup_page() self.setup_database() @@ -295,28 +329,405 @@ class FlutterStyleDashboard: """UIセットアップ""" # メインコンテンツ self.main_content = ft.Column([], expand=True) - + # ページ構成 self.page.add( ft.Column([ self.main_content, - ], expand=True) + ], expand=True), ) - + + self._settings_hide_task = None + + self.settings_panel_wrapper = ft.Container( + width=640, + bgcolor=ft.Colors.WHITE, + padding=ft.Padding.symmetric(horizontal=16, vertical=24), + shadow=[ft.BoxShadow(blur_radius=24, color=ft.Colors.BLACK12, offset=ft.Offset(6, 0))], + ) + self.settings_drawer_overlay = ft.Stack( + controls=[ + ft.Container( + expand=True, + bgcolor=ft.Colors.BLACK54, + on_click=self.close_settings_drawer, + ), + ft.Container( + expand=True, + alignment=ft.Alignment(-1, 0), + content=ft.Container( + content=self.settings_panel_wrapper, + width=640, + bgcolor=ft.Colors.WHITE, + ), + ), + ], + expand=True, + visible=False, + ) + self.page.overlay.append(self.settings_drawer_overlay) + # 初期表示 self.update_main_content() - def dispose(self, e=None): - """リソース解放""" + def dispose(self, _=None): + logging.info("FlutterStyleDashboard.dispose") try: - self.app_service.close() - except Exception as err: - logging.warning(f"クリーンアップ失敗: {err}") - + if self.page: + self.page.window.close() + except Exception as e: + logging.warning(f"dispose error: {e}") + + def open_settings_drawer(self, _=None): + if not hasattr(self, "settings_drawer_overlay"): + return + if self._settings_hide_task: + self._settings_hide_task.cancel() + self._settings_hide_task = None + self.settings_panel_wrapper.content = self._build_settings_panel() + self.settings_drawer_overlay.visible = True + self.page.update() + + def close_settings_drawer(self, _=None): + if not hasattr(self, "settings_drawer_overlay"): + return + self.page.update() + loop = asyncio.get_event_loop() + if self._settings_hide_task: + self._settings_hide_task.cancel() + self._settings_hide_task = loop.create_task(self._hide_settings_drawer_async()) + + async def _hide_settings_drawer_async(self): + await asyncio.sleep(0.3) + if hasattr(self, "settings_drawer_overlay"): + self.settings_drawer_overlay.visible = False + self.page.update() + self._settings_hide_task = None + + def _build_settings_panel(self) -> ft.Row: + sidebar_width = int(self.settings_panel_wrapper.width * 0.25) + sidebar = ft.Container( + width=sidebar_width, + content=ft.Column( + [ + ft.Text("メニュー", weight=ft.FontWeight.BOLD), + ft.Divider(), + ft.ListTile( + leading=ft.Icon(ft.Icons.PALETTE), + title=ft.Text("全体設定"), + selected=not self.is_company_settings_open, + on_click=lambda _: self._open_settings_page(False), + ), + ft.ListTile( + leading=ft.Icon(ft.Icons.BUSINESS), + title=ft.Text("自社情報"), + selected=self.is_company_settings_open, + on_click=lambda _: self._open_settings_page(True), + ), + ], + spacing=8, + expand=True, + ), + ) + + content = self._build_company_settings_content() if self.is_company_settings_open else self._build_settings_content() + + body = ft.Row( + [ + sidebar, + ft.VerticalDivider(width=1), + ft.Container(content=content, expand=True), + ], + expand=True, + ) + + header = ft.Row( + [ + ft.IconButton(ft.Icons.ARROW_BACK, tooltip="閉じる", on_click=self.close_settings_drawer), + ft.Text("設定", size=18, weight=ft.FontWeight.BOLD), + ], + spacing=8, + alignment=ft.MainAxisAlignment.START, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + + return ft.Column( + [ + header, + ft.Divider(), + body, + ], + spacing=8, + expand=True, + ) + + def _open_settings_page(self, company: bool): + self.is_company_settings_open = company + self.settings_panel_wrapper.content = self._build_settings_panel() + self.page.update() + + def _build_settings_content(self) -> ft.Column: + theme_radio = ft.RadioGroup( + value=self.settings_state.get("theme", self.current_theme), + on_change=self._on_theme_change, + content=ft.Column( + [ + ft.Radio(value=name, label=name.title()) + for name in self.theme_presets.keys() + ], + spacing=4, + ), + ) + + stay_switch = ft.Switch( + label="保存後も編集画面に留まる", + value=self.settings_state.get("stay_on_detail_after_save", self.stay_on_detail_after_save), + on_change=self._on_stay_setting_change, + ) + + def text_field(key: str, label: str, password: bool = False) -> ft.TextField: + return ft.TextField( + label=label, + value=self.settings_state.get(key, ""), + password=password, + can_reveal_password=password, + on_change=lambda e, k=key: self._on_settings_text_change(k, e.control.value), + ) + + smtp_section = ft.Column( + [ + ft.Text("SMTPサーバ設定", weight=ft.FontWeight.BOLD), + text_field("smtp_host", "ホスト"), + text_field("smtp_port", "ポート"), + text_field("smtp_username", "ユーザー名"), + text_field("smtp_password", "パスワード", password=True), + ], + spacing=8, + ) + + backup_section = ft.Column( + [ + ft.Text("バックアップ先", weight=ft.FontWeight.BOLD), + text_field("backup_path", "保存フォルダ"), + ], + spacing=8, + ) + + action_buttons = ft.Row( + [ + ft.ElevatedButton("保存", icon=ft.Icons.SAVE, on_click=self._save_settings_and_close), + ft.TextButton("閉じる", on_click=self.close_settings_drawer), + ], + spacing=12, + alignment=ft.MainAxisAlignment.END, + ) + + return ft.Column( + [ + ft.Text("設定", size=20, weight=ft.FontWeight.BOLD), + ft.Divider(), + ft.Text("テーマ", weight=ft.FontWeight.BOLD), + theme_radio, + stay_switch, + ft.Divider(), + smtp_section, + ft.Divider(), + backup_section, + ft.Divider(), + action_buttons, + ], + spacing=16, + scroll=ft.ScrollMode.AUTO, + ) + + def _build_company_settings_content(self) -> ft.Column: + def text_field(key: str, label: str, multiline: bool = False) -> ft.TextField: + return ft.TextField( + label=label, + value=self.settings_state.get(key, ""), + multiline=multiline, + min_lines=1 if not multiline else 2, + max_lines=4, + on_change=lambda e, k=key: self._on_settings_text_change(k, e.control.value), + ) + + def bank_entry(index: int, data: Dict[str, Any]) -> ft.Container: + prefix = f"bank_{index}" + + def on_field_change(e, field): + account = self._ensure_bank_account(index) + account[field] = e.control.value + self._save_bank_accounts() + + def on_active_change(e): + account = self._ensure_bank_account(index) + is_enabling = bool(e.control.value) + if is_enabling and self._active_bank_count() >= self.max_active_bank_accounts: + self._show_snack(f"請求書掲載は最大{self.max_active_bank_accounts}口座です", ft.Colors.RED_200) + e.control.value = False + self.page.update() + return + account["active"] = is_enabling + self._save_bank_accounts() + + is_active = data.get("active", False) + return ft.Container( + content=ft.Column( + [ + ft.Row( + [ + ft.Text(f"口座 {index + 1}", weight=ft.FontWeight.BOLD), + ft.Switch(label="請求書に掲載", value=is_active, on_change=on_active_change), + ], + alignment=ft.MainAxisAlignment.SPACE_BETWEEN, + ), + ft.TextField(label="銀行名", value=data.get("bank_name", ""), on_change=lambda e: on_field_change(e, "bank_name")), + ft.TextField(label="支店名", value=data.get("branch_name", ""), on_change=lambda e: on_field_change(e, "branch_name")), + ft.TextField(label="区分", value=data.get("account_type", "普通"), on_change=lambda e: on_field_change(e, "account_type")), + ft.TextField(label="口座番号", value=data.get("account_number", ""), on_change=lambda e: on_field_change(e, "account_number")), + ft.TextField(label="名義", value=data.get("holder", ""), on_change=lambda e: on_field_change(e, "holder")), + ], + spacing=8, + ), + border=ft.Border.all(1, ft.Colors.GREY_300), + border_radius=8, + padding=ft.Padding.all(12), + ) + + stamp_controls = ft.Column( + [ + ft.Text("印鑑アップロード", weight=ft.FontWeight.BOLD), + ft.Row([ + ft.Column([ + ft.Text("角印"), + ft.Row([ + ft.ElevatedButton("アップロード", on_click=lambda _: self._pick_stamp("corner_stamp_path")), + ft.Text(self._file_name(self.settings_state.get("corner_stamp_path"))), + ]), + ]), + ft.Column([ + ft.Text("担当者印"), + ft.Row([ + ft.ElevatedButton("アップロード", on_click=lambda _: self._pick_stamp("rep_stamp_path")), + ft.Text(self._file_name(self.settings_state.get("rep_stamp_path"))), + ]), + ]), + ], spacing=16), + ], + spacing=8, + ) + + accounts = self.settings_state.get("bank_accounts", []) + while len(accounts) < 4: + accounts.append({"active": False}) + account_cards = [bank_entry(i, accounts[i]) for i in range(4)] + + company_fields = ft.Column( + [ + text_field("company_name", "会社名"), + text_field("company_kana", "会社名 (カナ)"), + text_field("company_address", "住所", multiline=True), + text_field("company_phone", "電話番号"), + ft.TextField( + label="代表者名", + value=self.settings_state.get("company_representative", ""), + on_change=lambda e: self._on_settings_text_change("company_representative", e.control.value), + ), + ft.TextField( + label="連絡先メール", + value=self.settings_state.get("company_email", ""), + on_change=lambda e: self._on_settings_text_change("company_email", e.control.value), + ), + ], + spacing=10, + ) + + return ft.Column( + [ + ft.Text("自社情報設定", size=18, weight=ft.FontWeight.BOLD), + ft.Divider(), + company_fields, + ft.Divider(), + ft.Text("銀行口座", weight=ft.FontWeight.BOLD), + ft.Text("最大4口座登録可能・2口座まで請求書に掲載できます"), + ft.Column(account_cards, spacing=12), + ft.Divider(), + stamp_controls, + ft.Divider(), + ft.Row([ + ft.ElevatedButton("保存", icon=ft.Icons.SAVE, on_click=self._save_settings_and_close), + ft.TextButton("閉じる", on_click=self.close_settings_drawer), + ], alignment=ft.MainAxisAlignment.END, spacing=12), + ], + spacing=16, + expand=True, + scroll=ft.ScrollMode.AUTO, + ) + + def _ensure_bank_account(self, index: int) -> Dict[str, Any]: + accounts = self.settings_state.setdefault("bank_accounts", []) + while len(accounts) <= index: + accounts.append({"active": False}) + return accounts[index] + + def _save_bank_accounts(self): + self.page.update() + + def _active_bank_count(self) -> int: + accounts = self.settings_state.get("bank_accounts", []) + return sum(1 for acc in accounts if acc.get("active")) + + def _file_name(self, path: str) -> str: + if not path: + return "未設定" + return path.split("/")[-1] + + def _pick_stamp(self, target_key: str): + if self._stamp_picker is None: + self._show_snack("この環境では印鑑アップロードに対応していません", ft.Colors.RED_200) + return + self._pending_stamp_target = target_key + self._stamp_picker.pick_files(allow_multiple=False) + + def _on_stamp_file_picked(self, e): + if not e.files or not self._pending_stamp_target: + return + file = e.files[0] + temp_path = file.path or file.name + self.settings_state[self._pending_stamp_target] = temp_path + self._pending_stamp_target = None + self.settings_panel_wrapper.content = self._build_settings_panel() + self.page.update() + + def _on_theme_change(self, e: ft.ControlEvent): + new_theme = e.control.value or "light" + if new_theme == self.current_theme: + return + self.settings_state["theme"] = new_theme + self.apply_theme(new_theme) + self.update_main_content() + + def _on_stay_setting_change(self, e: ft.ControlEvent): + value = bool(e.control.value) + self.settings_state["stay_on_detail_after_save"] = value + self.stay_on_detail_after_save = value + + def _on_settings_text_change(self, key: str, value: str): + self.settings_state[key] = value + + def _save_settings_and_close(self, _=None): + self._show_snack("設定を保存しました", ft.Colors.BLUE_GREY_600) + self.close_settings_drawer() + + def toggle_reorder_mode(self, _=None): + self.is_reorder_mode = not self.is_reorder_mode + self.update_main_content() + def on_tab_change(self, index): """タブ切り替え""" self.current_tab = index self.update_main_content() + self.page.update() @log_wrap("update_main_content") @@ -364,23 +775,22 @@ class FlutterStyleDashboard: def _build_invoice_list_screen(self) -> ft.Column: """伝票一覧画面を構築""" logging.info("_build_invoice_list_screen: 開始") - + # AppBar(戻るボタンなし、編集ボタンなし) + settings_button = ft.IconButton( + icon=ft.Icons.MENU, + tooltip="設定", + on_click=self.open_settings_drawer, + ) + app_bar = AppBar( title="伝票一覧", show_back=False, show_edit=False, trailing_controls=[ - ft.Button( - content=ft.Row([ - ft.Icon(ft.Icons.ADD), - ft.Text("新規伝票"), - ], spacing=6), - on_click=lambda _: self.start_new_invoice(), - height=36, - style=ft.ButtonStyle(padding=ft.Padding.symmetric(horizontal=10, vertical=0)), - ) + ft.IconButton(ft.Icons.REFRESH, on_click=lambda _: self.refresh_invoices()), ], + leading_control=settings_button, ) logging.info("_build_invoice_list_screen: AppBar作成完了") @@ -1256,6 +1666,7 @@ class FlutterStyleDashboard: def set_doc_type(dt: DocumentType): if self.is_detail_edit_mode and not is_locked: self.select_document_type(dt) + self.update_main_content() doc_type_items = [ ft.PopupMenuItem( @@ -1270,19 +1681,51 @@ class FlutterStyleDashboard: self.editing_invoice.is_draft = bool(e.control.value) self.update_main_content() - if self.is_detail_edit_mode and not is_locked: - doc_type_menu = ft.PopupMenuButton( - content=ft.Text( + doc_type_label = ft.Row( + [ + ft.Icon(ft.Icons.DESCRIPTION, size=14, color=ft.Colors.BLUE_GREY_700), + ft.Text( self.selected_document_type.value, size=12, weight=ft.FontWeight.BOLD, - color=ft.Colors.WHITE, + color=ft.Colors.BLUE_GREY_800, ), + ], + spacing=4, + vertical_alignment=ft.CrossAxisAlignment.CENTER, + ) + doc_type_chip = ft.Container( + content=doc_type_label, + padding=ft.Padding.symmetric(horizontal=12, vertical=6), + bgcolor=ft.Colors.WHITE, + border=ft.border.all(1, ft.Colors.BLUE_200), + border_radius=8, + ) + + if self.is_detail_edit_mode and not is_locked: + doc_type_control = ft.PopupMenuButton( + content=doc_type_chip, items=doc_type_items, tooltip="帳票タイプ変更", ) else: - doc_type_menu = None + doc_type_control = doc_type_chip + + if self.is_detail_edit_mode and not is_locked: + draft_control = ft.Switch( + label="下書き", + value=is_draft, + on_change=toggle_draft, + ) + elif is_draft: + draft_control = ft.Container( + content=ft.Text("下書き", size=11, color=ft.Colors.BROWN_800), + padding=ft.Padding.symmetric(horizontal=8, vertical=4), + bgcolor=ft.Colors.BROWN_100, + border_radius=12, + ) + else: + draft_control = ft.Container() # 日付・時間ピッカー(テキスト入力NGなのでボタン+ポップアップ) date_button = None @@ -1299,18 +1742,21 @@ class FlutterStyleDashboard: self._time_picker = ft.TimePicker() self.page.overlay.append(self._time_picker) + def _parse_picker_date(value) -> date: + if isinstance(value, datetime): + return value.date() + if hasattr(value, "year") and hasattr(value, "month") and hasattr(value, "day"): + return date(value.year, value.month, value.day) + raw = str(value) + if "T" in raw: + raw = raw.split("T")[0] + return date.fromisoformat(raw) + def on_date_change(e): if not e.data: return try: - if isinstance(e.data, datetime): - picked_date = e.data - elif hasattr(e.data, "year") and hasattr(e.data, "month") and hasattr(e.data, "day"): - picked_date = datetime(e.data.year, e.data.month, e.data.day) - else: - raw = str(e.data) - picked_date = datetime.fromisoformat(raw) - + picked_date = _parse_picker_date(e.data) current = self.editing_invoice.date self.editing_invoice.date = datetime( picked_date.year, picked_date.month, picked_date.day, @@ -1361,6 +1807,7 @@ class FlutterStyleDashboard: ft.Text(self.editing_invoice.date.strftime("%Y/%m/%d"), size=12), ], spacing=6), on_click=lambda _: self._open_date_picker(), + style=self.edit_button_style, ) time_button = ft.Button( content=ft.Row([ @@ -1368,6 +1815,7 @@ class FlutterStyleDashboard: ft.Text(self.editing_invoice.date.strftime("%H:%M"), size=12), ], spacing=6), on_click=lambda _: self._open_time_picker(), + style=self.edit_button_style, ) # 備考フィールド @@ -1567,15 +2015,11 @@ class FlutterStyleDashboard: if (not is_view_mode and not is_locked) or is_new_invoice: customer_control = ft.Button( - content=ft.Text(customer_label, no_wrap=True), + content=ft.Text(customer_label, no_wrap=True, color=ft.Colors.BLUE_GREY_800), width=220, height=36, on_click=lambda _: select_customer(), - style=ft.ButtonStyle( - padding=ft.Padding.symmetric(horizontal=10, vertical=6), - bgcolor=ft.Colors.BLUE_GREY_100, - shape=ft.RoundedRectangleBorder(radius=6), - ), + style=self.edit_button_style, ) else: customer_control = ft.Text( @@ -1584,42 +2028,6 @@ class FlutterStyleDashboard: weight=ft.FontWeight.BOLD, ) - if self.is_detail_edit_mode and not is_locked: - doc_type_control = ft.PopupMenuButton( - content=ft.Text( - self.selected_document_type.value, - size=12, - weight=ft.FontWeight.BOLD, - color=ft.Colors.WHITE, - ), - items=doc_type_items, - tooltip="帳票タイプ変更", - ) - else: - doc_type_control = ft.Text( - self.selected_document_type.value, - size=12, - weight=ft.FontWeight.BOLD, - color=ft.Colors.WHITE, - ) - - draft_control: ft.Control - if self.is_detail_edit_mode and not is_locked: - draft_control = ft.Switch( - label="下書き", - value=is_draft, - on_change=toggle_draft, - ) - elif is_draft: - draft_control = ft.Container( - content=ft.Text("下書き", size=11, color=ft.Colors.BROWN_800), - padding=ft.Padding.symmetric(horizontal=8, vertical=4), - bgcolor=ft.Colors.BROWN_100, - border_radius=12, - ) - else: - draft_control = ft.Container() - date_time_row = ft.Row( [ date_button if date_button else ft.Text( @@ -1633,7 +2041,7 @@ class FlutterStyleDashboard: color=ft.Colors.BLUE_GREY_600, ), ], - spacing=8, + spacing=12, ) customer_block = ft.Column( @@ -1652,17 +2060,7 @@ class FlutterStyleDashboard: [ ft.Row( [ - ft.Container( - content=doc_type_control if self.is_detail_edit_mode and not is_locked else ft.Text( - self.selected_document_type.value, - size=9, - weight=ft.FontWeight.BOLD, - color=self.invoice_card_theme["tag_text_color"], - ), - padding=ft.Padding.symmetric(horizontal=8, vertical=2), - bgcolor=self.invoice_card_theme["tag_bg"], - border_radius=10, - ), + doc_type_control, ft.Text( f"No: {self.editing_invoice.invoice_number}", size=10, @@ -1701,14 +2099,26 @@ class FlutterStyleDashboard: [ ft.Text("明細", size=13, weight=ft.FontWeight.BOLD), ft.Container(expand=True), - ft.IconButton( - ft.Icons.ADD_CIRCLE_OUTLINE, - tooltip="行を追加", - icon_color=ft.Colors.GREEN_600, + ft.Button( + content=ft.Row([ + ft.Icon(ft.Icons.SHUFFLE, size=16, color=ft.Colors.BLUE_GREY_700), + ft.Text("並べ替え" + ("ON" if self.is_reorder_mode else ""), size=12), + ], spacing=6), + style=self.edit_button_style, + on_click=self.toggle_reorder_mode, disabled=is_locked or is_view_mode, + ) if not is_locked and not is_view_mode else ft.Container(), + ft.Button( + content=ft.Row([ + ft.Icon(ft.Icons.ADD_CIRCLE_OUTLINE, size=16, color=ft.Colors.BLUE_GREY_700), + ft.Text("行追加", size=12), + ], spacing=6), + style=self.edit_button_style, on_click=lambda _: self._add_item_row(), + disabled=is_locked or is_view_mode, ) if not is_locked and not is_view_mode else ft.Container(), ], + spacing=8, vertical_alignment=ft.CrossAxisAlignment.CENTER, ), ft.Container( @@ -1734,13 +2144,10 @@ class FlutterStyleDashboard: [ ft.Button( content=ft.Row([ - ft.Icon(ft.Icons.DOWNLOAD, size=16), + ft.Icon(ft.Icons.DOWNLOAD, size=16, color=ft.Colors.BLUE_GREY_700), ft.Text("PDF生成", size=12), ], spacing=6), - style=ft.ButtonStyle( - bgcolor=ft.Colors.BLUE_600, - color=ft.Colors.WHITE, - ), + style=self.edit_button_style, on_click=lambda _: self.generate_pdf_from_edit(), disabled=is_locked, ) if not is_locked else ft.Container(), @@ -1942,7 +2349,7 @@ class FlutterStyleDashboard: products=self.app_service.product.get_all_products(), on_product_select=self._open_product_picker_for_row, row_refs=self._ensure_item_row_refs(), - enable_reorder=not is_locked, + enable_reorder=not is_locked and self.is_reorder_mode, on_reorder=lambda old, new: self._reorder_item_row(old, new), ) @@ -2154,6 +2561,101 @@ class FlutterStyleDashboard: self.page.update() except Exception as e: logging.warning(f"toast hide failed: {e}") + + def _build_theme_presets(self) -> Dict[str, Dict[str, Any]]: + common_radius = 18 + light_palette = { + "page_bg": "#F3F2FB", + "card_bg": ft.Colors.WHITE, + "card_radius": common_radius, + "shadow": ft.BoxShadow( + blur_radius=16, + spread_radius=0, + color="#D5D8F0", + offset=ft.Offset(0, 6), + ), + "icon_default_bg": "#5C6BC0", + "title_color": ft.Colors.BLUE_GREY_900, + "subtitle_color": ft.Colors.BLUE_GREY_500, + "amount_color": "#2F3C7E", + "tag_text_color": "#4B4F67", + "tag_bg": "#E7E9FB", + "draft_card_bg": "#F5EEE4", + "draft_border": "#D7C4AF", + "draft_shadow_highlight": ft.BoxShadow( + blur_radius=8, + spread_radius=0, + color="#FFFFFF", + offset=ft.Offset(-2, -2), + ), + "draft_shadow_depth": ft.BoxShadow( + blur_radius=14, + spread_radius=2, + color="#C3A88C", + offset=ft.Offset(4, 6), + ), + "badge_bg": "#35C46B", + "doc_type_palette": { + DocumentType.INVOICE.value: "#5C6BC0", + DocumentType.ESTIMATE.value: "#7E57C2", + DocumentType.DELIVERY.value: "#26A69A", + DocumentType.RECEIPT.value: "#FF7043", + DocumentType.SALES.value: "#42A5F5", + DocumentType.DRAFT.value: "#90A4AE", + }, + } + + monokai_palette = { + "page_bg": "#272822", + "card_bg": "#3E3D32", + "card_radius": common_radius, + "shadow": ft.BoxShadow( + blur_radius=12, + spread_radius=0, + color="#00000055", + offset=ft.Offset(0, 4), + ), + "icon_default_bg": "#F92672", + "title_color": "#F8F8F2", + "subtitle_color": "#A6E22E", + "amount_color": "#66D9EF", + "tag_text_color": "#F8F8F2", + "tag_bg": "#75715E", + "draft_card_bg": "#4F3F2F", + "draft_border": "#CDAA7D", + "draft_shadow_highlight": ft.BoxShadow( + blur_radius=6, + spread_radius=0, + color="#ffffff22", + offset=ft.Offset(-1, -1), + ), + "draft_shadow_depth": ft.BoxShadow( + blur_radius=10, + spread_radius=0, + color="#00000055", + offset=ft.Offset(2, 3), + ), + "badge_bg": "#AE81FF", + "doc_type_palette": { + DocumentType.INVOICE.value: "#F92672", + DocumentType.ESTIMATE.value: "#AE81FF", + DocumentType.DELIVERY.value: "#A6E22E", + DocumentType.RECEIPT.value: "#FD971F", + DocumentType.SALES.value: "#66D9EF", + DocumentType.DRAFT.value: "#75715E", + }, + } + + return { + "light": light_palette, + "monokai": monokai_palette, + } + + def apply_theme(self, name: str): + preset = self.theme_presets.get(name) or self.theme_presets.get("light") + self.current_theme = name if name in self.theme_presets else "light" + self.invoice_card_theme = {k: v for k, v in preset.items() if k != "doc_type_palette"} + self.doc_type_palette = preset["doc_type_palette"].copy() def create_new_customer_screen(self) -> ft.Container: """新規/既存顧客登録・編集画面""" editing_customer = getattr(self, "editing_customer_for_form", None)