""" インタラクティブ伝票ビューア Flutter参考プロジェクトの構造を適用 """ import flet as ft import sqlite3 import signal import sys import logging from datetime import datetime from components.pinch_handler import PinchHandler from models.invoice_models import DocumentType, Invoice, create_sample_invoices # カラーテーマ定義 DARK_THEME = { 'background': ft.Colors.GREY_900, 'card_bg': ft.Colors.GREY_800, 'text_primary': ft.Colors.WHITE, 'text_secondary': ft.Colors.GREY_300, 'accent': ft.Colors.BLUE_400 } LIGHT_THEME = { 'background': ft.Colors.BLUE_50, 'card_bg': ft.Colors.WHITE, 'text_primary': ft.Colors.BLACK, 'text_secondary': ft.Colors.GREY_700, 'accent': ft.Colors.BLUE_600 } class InteractiveSlipViewer: def __init__(self, page: ft.Page): self.page = page # 状態管理を先に初期化 self.slip_data = [] self.test_mode = False # テストモード self.test_logs = [] # 操作ログ self.is_dark_theme = False # テーマ設定 # ページ設定 self.setup_page() # データベース設定 self.setup_database() # ピンチハンドラー初期化 self.pinch_handler = PinchHandler(page) self.pinch_handler.set_callbacks( on_zoom_change=self.on_zoom_change, on_tap=self.on_slip_tap, on_double_tap=self.on_slip_double_tap, on_long_press=self.on_slip_long_press ) # UI初期化 self.setup_ui() def setup_page(self): self.page.title = "インタラクティブ伝票ビューア" self.page.window.width = 420 self.page.window.height = 900 self.page.window.resizable = False self.page.window_center = True # キーボードイベントハンドラー self.page.on_keyboard_event = self.on_keyboard_event def setup_database(self): try: self.conn = sqlite3.connect('sales_assist.db') self.cursor = self.conn.cursor() # 伝票テーブル作成 self.cursor.execute(''' CREATE TABLE IF NOT EXISTS slips ( id INTEGER PRIMARY KEY AUTOINCREMENT, slip_type TEXT, customer_name TEXT, amount REAL, date TEXT, status TEXT, description TEXT, created_at TEXT DEFAULT CURRENT_TIMESTAMP ) ''') # サンプルデータ self.create_sample_data() except Exception as e: logging.error(f"DBエラー: {e}") self.conn = None def create_sample_data(self): """サンプル伝票データ作成""" try: # Flutterモデルからサンプルデータを生成 sample_invoices = create_sample_invoices() for invoice in sample_invoices: self.cursor.execute(''' INSERT OR REPLACE INTO slips (slip_type, customer_name, amount, date, status, description) VALUES (?, ?, ?, ?, ?, ?) ''', ( invoice.document_type.value, invoice.customer.formal_name, invoice.total_amount, invoice.date.strftime('%Y-%m-%d'), '完了', invoice.notes )) self.conn.commit() except Exception as e: logging.error(f"サンプルデータ作成エラー: {e}") def setup_ui(self): # テーマ設定 theme = DARK_THEME if self.is_dark_theme else LIGHT_THEME # ヘッダー header = ft.Container( content=ft.Row([ ft.Text("📋 インタラクティブ伝票", size=18, weight=ft.FontWeight.BOLD, color=theme['text_primary']), ft.Container(expand=True), ft.IconButton(ft.Icons.SEARCH, icon_size=16, icon_color=theme['text_primary']), ft.Button( "テスト", bgcolor=theme['accent'], color=theme['text_primary'], on_click=self.toggle_test_mode ) ]), padding=15, bgcolor=theme['accent'], border_radius=15, margin=ft.Margin.only(bottom=10) ) # 伝票グリッド self.slip_grid = ft.GridView( runs_count=2, spacing=10, run_spacing=10, child_aspect_ratio=1.2, padding=ft.Padding.symmetric(horizontal=15, vertical=10) ) # 詳細パネル self.detail_panel = ft.Container( content=ft.Text("詳細パネル"), visible=False, padding=15, bgcolor=theme['card_bg'], border_radius=10, shadow=ft.BoxShadow( spread_radius=1, blur_radius=5, color=ft.Colors.with_opacity(0.2, ft.Colors.BLACK), offset=ft.Offset(0, 2) ) ) # テストモードパネル self.test_panel = ft.Container( content=ft.Column([ ft.Text("🧪 テストモード", size=14, weight=ft.FontWeight.BOLD, color=theme['text_primary']), ft.Divider(height=1), ft.Text("キーボード操作:", size=12, weight=ft.FontWeight.BOLD, color=theme['text_primary']), ft.Text("+ : ズームイン", size=10, color=theme['text_secondary']), ft.Text("- : ズームアウト", size=10, color=theme['text_secondary']), ft.Text("R : ズームリセット", size=10, color=theme['text_secondary']), ft.Text(f"現在のズーム: {int(self.pinch_handler.zoom_level * 100)}%", size=10, color=theme['text_primary']), ft.Text("操作履歴:", size=12, weight=ft.FontWeight.BOLD, color=theme['text_primary']), ft.Column([], spacing=2) ], spacing=5), visible=False, padding=10, bgcolor=theme['card_bg'], border_radius=10, margin=ft.Margin.only(bottom=10) ) # メインコンテナ main_container = ft.Column([ self.slip_grid, self.test_panel, self.detail_panel ], scroll=ft.ScrollMode.AUTO) # ページに追加 self.page.add( ft.Column([ header, ft.Container( content=main_container, expand=True, bgcolor=theme['background'] ) ]) ) # 初期データ読み込み self.load_slips() def load_slips(self): """伝票データ読み込み""" if not self.conn: logging.error("データベース接続がありません") return try: self.cursor.execute("SELECT * FROM slips ORDER BY date DESC") rows = self.cursor.fetchall() # タプルからInvoiceオブジェクトに変換 self.slip_data = [] for row in rows: # 簡単なInvoiceオブジェクトを作成(復元用) from models.invoice_models import Customer, InvoiceItem, DocumentType customer = Customer(1, row[2], row[2]) # id, name, formal_name items = [InvoiceItem("サンプル明細", 1, int(row[3]))] # description, quantity, unit_price # DocumentTypeの文字列からEnumに変換 doc_type_str = row[1] doc_type = next((dt for dt in DocumentType if dt.value == doc_type_str), DocumentType.INVOICE) invoice = Invoice( customer=customer, date=datetime.strptime(row[4], '%Y-%m-%d'), items=items, document_type=doc_type ) self.slip_data.append(invoice) self.update_slip_grid() except Exception as e: logging.error(f"伝票読み込みエラー: {e}") self.slip_data = [] def update_slip_grid(self): """伝票グリッド更新""" self.slip_grid.controls.clear() for slip in self.slip_data: slip_card = self.create_slip_card(slip) self.slip_grid.controls.append(slip_card) self.page.update() def create_slip_card(self, invoice: Invoice) -> ft.Container: """伝票カード作成""" # テーマ取得 theme = DARK_THEME if self.is_dark_theme else LIGHT_THEME # ドキュメントタイプに応じたアイコンと色 type_config = { DocumentType.SALES: {"icon": "💰", "color": ft.Colors.GREEN}, DocumentType.ESTIMATE: {"icon": "📄", "color": ft.Colors.BLUE}, DocumentType.DELIVERY: {"icon": "📦", "color": ft.Colors.PURPLE}, DocumentType.INVOICE: {"icon": "📋", "color": ft.Colors.ORANGE}, DocumentType.RECEIPT: {"icon": "🧾", "color": ft.Colors.RED} } config = type_config.get(invoice.document_type, {"icon": "📝", "color": ft.Colors.GREY}) # 通常のカード作成 card = ft.Container( content=ft.Column([ ft.Container( content=ft.Text(config["icon"], size=24), width=40, height=40, bgcolor=config["color"], border_radius=20, alignment=ft.alignment.Alignment(0, 0) ), ft.Text(invoice.document_type.value, size=12, weight=ft.FontWeight.BOLD, color=theme['text_primary']), ft.Text(f"{invoice.customer.formal_name} ¥{invoice.total_amount:,}", size=10, color=theme['text_secondary']), ], spacing=5, horizontal_alignment=ft.CrossAxisAlignment.CENTER), padding=10, bgcolor=theme['card_bg'], border_radius=10, shadow=ft.BoxShadow( spread_radius=1, blur_radius=5, color=ft.Colors.with_opacity(0.2, ft.Colors.BLACK), offset=ft.Offset(0, 2) ) ) return ft.GestureDetector( content=card, on_tap=lambda _: self.on_slip_tap(invoice) ) def on_zoom_change(self, zoom_level: float): """ズームレベル変更時""" # ズーム機能は一時的に無効化 pass def on_slip_tap(self, invoice: Invoice): """伝票タップ""" self.show_slip_detail(invoice) def on_slip_double_tap(self, invoice: Invoice): """伝票ダブルタップ""" self.show_slip_detail(invoice) def on_slip_long_press(self, invoice: Invoice): """伝票ロングプレス""" self.show_slip_detail(invoice) def show_slip_detail(self, invoice: Invoice): """伝票詳細表示""" # テーマ取得 theme = DARK_THEME if self.is_dark_theme else LIGHT_THEME self.detail_panel.content = ft.Column([ ft.Row([ ft.Text("伝票詳細", size=16, weight=ft.FontWeight.BOLD, color=theme['text_primary']), ft.IconButton(ft.Icons.CLOSE, on_click=self.hide_detail, icon_color=theme['text_primary']) ]), ft.Divider(), ft.Text(f"種類: {invoice.document_type.value}", size=14, color=theme['text_primary']), ft.Text(f"顧客: {invoice.customer.formal_name}", size=14, color=theme['text_primary']), ft.Text(f"金額: ¥{invoice.total_amount:,}", size=14, color=theme['text_primary']), ft.Text(f"日付: {invoice.date.strftime('%Y/%m/%d')}", size=14, color=theme['text_primary']), ft.Text(f"請求書番号: {invoice.invoice_number}", size=14, color=theme['text_primary']), ft.Text(f"説明: {invoice.notes or 'なし'}", size=12, color=theme['text_secondary']), ft.Row([ ft.Button("編集", bgcolor=theme['accent'], color=theme['text_primary']), ft.Button("削除", bgcolor=ft.Colors.RED, color=theme['text_primary']), ], spacing=10) ], spacing=10) self.detail_panel.visible = True self.page.update() def on_keyboard_event(self, e: ft.KeyboardEvent): """キーボードイベント処理""" if self.test_mode: if e.key == "+": self.pinch_handler.zoom_in() self.add_test_log("キーボード: + (ズームイン)") elif e.key == "-": self.pinch_handler.zoom_out() self.add_test_log("キーボード: - (ズームアウト)") elif e.key == "r": self.pinch_handler.reset_zoom() self.add_test_log("キーボード: R (ズームリセット)") elif e.key == " ": # Spaceキー self.add_test_log("キーボード: Space (ダブルタップ代替)") elif e.key == "t": # Tキー self.toggle_test_mode() def on_mouse_event(self, e): """マウスイベント処理""" if self.test_mode: if hasattr(e, 'button'): if e.button == "right": self.pinch_handler.zoom_in() self.add_test_log(f"マウス: 右クリック (ズームイン)") elif e.button == "middle": self.pinch_handler.zoom_out() self.add_test_log(f"マウス: 中クリック (ズームアウト)") def toggle_test_mode(self): """テストモード切替""" self.test_mode = not self.test_mode self.test_panel.visible = self.test_mode self.page.update() def add_test_log(self, message: str): """テストログ追加""" if hasattr(self, 'test_logs'): self.test_logs.append(message) else: self.test_logs = [message] # ログ表示更新 if len(self.test_logs) > 10: # 最新10件のみ表示 self.test_logs = self.test_logs[-10:] # テーマ取得 theme = DARK_THEME if self.is_dark_theme else LIGHT_THEME log_container = ft.Column([ ft.Text(log, size=8, color=theme['text_secondary']) for log in self.test_logs ]) self.test_panel.content.controls[-1].controls = [log_container] self.page.update() def hide_detail(self, e=None): """詳細パネル非表示""" self.detail_panel.visible = False self.page.update() def main(page: ft.Page): try: viewer = InteractiveSlipViewer(page) logging.info("インタラクティブ伝票ビューア起動") except Exception as e: logging.error(f"起動エラー: {e}") if __name__ == "__main__": ft.run(main)