mini汚染を修復

This commit is contained in:
joe 2026-02-24 10:10:16 +09:00
parent 3e4af336f8
commit 0f86ee14ac
2 changed files with 625 additions and 120 deletions

View file

@ -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(
[

692
main.py
View file

@ -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()
@ -300,23 +334,400 @@ class FlutterStyleDashboard:
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")
@ -366,21 +777,20 @@ class FlutterStyleDashboard:
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)