412 lines
15 KiB
Python
412 lines
15 KiB
Python
"""
|
||
インタラクティブ伝票ビューア
|
||
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)
|