From 214a7065a2e175c2f138ab6302259becbd1e4881 Mon Sep 17 00:00:00 2001 From: joe Date: Sun, 1 Feb 2026 12:12:35 +0900 Subject: [PATCH] =?UTF-8?q?=E9=A1=A7=E5=AE=A2=E3=83=9E=E3=82=B9=E3=82=BF?= =?UTF-8?q?=E3=83=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gemi_invoice/lib/main.dart | 113 +++++- gemi_invoice/lib/models/company_model.dart | 106 ++++++ gemi_invoice/lib/models/invoice_models.dart | 47 ++- .../lib/screens/company_editor_screen.dart | 206 +++++++++++ .../lib/screens/invoice_detail_page.dart | 343 ++++++++++++------ .../lib/screens/invoice_history_screen.dart | 186 ++++++++++ .../lib/screens/invoice_input_screen.dart | 226 +++++++----- .../lib/screens/product_picker_modal.dart | 81 +++-- .../lib/services/invoice_repository.dart | 18 +- .../lib/services/master_repository.dart | 150 ++++++++ gemi_invoice/lib/services/pdf_generator.dart | 141 +++++-- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + gemi_invoice/pubspec.lock | 34 +- gemi_invoice/pubspec.yaml | 2 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 18 files changed, 1387 insertions(+), 277 deletions(-) create mode 100644 gemi_invoice/lib/models/company_model.dart create mode 100644 gemi_invoice/lib/screens/company_editor_screen.dart create mode 100644 gemi_invoice/lib/screens/invoice_history_screen.dart create mode 100644 gemi_invoice/lib/services/master_repository.dart diff --git a/gemi_invoice/lib/main.dart b/gemi_invoice/lib/main.dart index 8bbb9c4..42acd54 100644 --- a/gemi_invoice/lib/main.dart +++ b/gemi_invoice/lib/main.dart @@ -1,16 +1,19 @@ // lib/main.dart -// version: 1.4.3c (Bug Fix: PDF layout error) - Refactored for modularity +// version: 1.4.3c (Bug Fix: PDF layout error) - Refactored for modularity and history management import 'package:flutter/material.dart'; // --- 独自モジュールのインポート --- -import 'models/invoice_models.dart'; // Invoice, InvoiceItem モデル -import 'screens/invoice_input_screen.dart'; // 入力フォーム画面 -import 'screens/invoice_detail_page.dart'; // 詳細表示・編集画面 +import 'models/invoice_models.dart'; +import 'screens/invoice_input_screen.dart'; +import 'screens/invoice_detail_page.dart'; +import 'screens/invoice_history_screen.dart'; +import 'screens/company_editor_screen.dart'; // 自社情報エディタをインポート void main() { runApp(const MyApp()); } +// アプリケーションのルートウィジェット class MyApp extends StatelessWidget { const MyApp({super.key}); @@ -22,30 +25,87 @@ class MyApp extends StatelessWidget { primarySwatch: Colors.blueGrey, visualDensity: VisualDensity.adaptivePlatformDensity, useMaterial3: true, + fontFamily: 'IPAexGothic', ), - home: const InvoiceFlowScreen(), + home: const MainNavigationShell(), ); } } -class InvoiceFlowScreen extends StatefulWidget { - const InvoiceFlowScreen({super.key}); +/// 下部ナビゲーションを管理するメインシェル +class MainNavigationShell extends StatefulWidget { + const MainNavigationShell({super.key}); @override - State createState() => _InvoiceFlowScreenState(); + State createState() => _MainNavigationShellState(); } -class _InvoiceFlowScreenState extends State { - // 最後に生成されたデータを保持(必要に応じて) - Invoice? _lastGeneratedInvoice; +class _MainNavigationShellState extends State { + int _selectedIndex = 0; + + // 各タブの画面リスト + final List _screens = []; + + @override + void initState() { + super.initState(); + _screens.addAll([ + InvoiceFlowScreen(onMoveToHistory: () => _onItemTapped(1)), + const InvoiceHistoryScreen(), + ]); + } + + void _onItemTapped(int index) { + setState(() { + _selectedIndex = index; + }); + } + + // 自社情報エディタ画面を開く + void _openCompanyEditor(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CompanyEditorScreen(), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: IndexedStack( + index: _selectedIndex, + children: _screens, + ), + bottomNavigationBar: BottomNavigationBar( + items: const [ + BottomNavigationBarItem( + icon: Icon(Icons.add_box), + label: '新規作成', + ), + BottomNavigationBarItem( + icon: Icon(Icons.history), + label: '発行履歴', + ), + ], + currentIndex: _selectedIndex, + selectedItemColor: Colors.indigo, + onTap: _onItemTapped, + ), + ); + } +} + +/// 請求書入力フローを管理するラッパー +class InvoiceFlowScreen extends StatelessWidget { + final VoidCallback onMoveToHistory; + + const InvoiceFlowScreen({super.key, required this.onMoveToHistory}); // PDF 生成後に呼び出され、詳細ページへ遷移するコールバック - void _handleInvoiceGenerated(Invoice generatedInvoice, String filePath) { - setState(() { - _lastGeneratedInvoice = generatedInvoice; - }); - - // 詳細ページへ遷移 + void _handleInvoiceGenerated(BuildContext context, Invoice generatedInvoice, String filePath) { + // PDF生成・DB保存後に詳細ページへ遷移 Navigator.push( context, MaterialPageRoute( @@ -54,16 +114,31 @@ class _InvoiceFlowScreenState extends State { ); } + // 自社情報エディタ画面を開く(タイトル長押し用) + void _openCompanyEditor(BuildContext context) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const CompanyEditorScreen(), + ), + ); + } + @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: const Text("販売アシスト1号 V1.4.3c"), + // アプリタイトルを長押しで自社情報エディタを開く + title: GestureDetector( + onLongPress: () => _openCompanyEditor(context), + child: const Text("販売アシスト1号 V1.4.3c"), + ), backgroundColor: Colors.blueGrey, + foregroundColor: Colors.white, ), // 入力フォームを表示 body: InvoiceInputForm( - onInvoiceGenerated: _handleInvoiceGenerated, + onInvoiceGenerated: (invoice, path) => _handleInvoiceGenerated(context, invoice, path), ), ); } diff --git a/gemi_invoice/lib/models/company_model.dart b/gemi_invoice/lib/models/company_model.dart new file mode 100644 index 0000000..79017cc --- /dev/null +++ b/gemi_invoice/lib/models/company_model.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; + +/// 自社情報を管理するモデル +/// 請求書などに記載される自社の正式名称、住所、連絡先など +class Company { + final String id; // ローカル管理用ID (シングルトンなので固定) + final String formalName; // 正式名称 (例: 株式会社 〇〇) + final String? representative; // 代表者名 + final String? zipCode; // 郵便番号 + final String? address; // 住所 + final String? tel; // 電話番号 + final String? fax; // FAX番号 + final String? email; // メールアドレス + final String? website; // ウェブサイト + final String? registrationNumber; // 登録番号 (インボイス制度対応) + final String? notes; // 備考 + + const Company({ + required this.id, + required this.formalName, + this.representative, + this.zipCode, + this.address, + this.tel, + this.fax, + this.email, + this.website, + this.registrationNumber, + this.notes, + }); + + /// 状態更新のためのコピーメソッド + Company copyWith({ + String? id, + String? formalName, + String? representative, + String? zipCode, + String? address, + String? tel, + String? fax, + String? email, + String? website, + String? registrationNumber, + String? notes, + }) { + return Company( + id: id ?? this.id, + formalName: formalName ?? this.formalName, + representative: representative ?? this.representative, + zipCode: zipCode ?? this.zipCode, + address: address ?? this.address, + tel: tel ?? this.tel, + fax: fax ?? this.fax, + email: email ?? this.email, + website: website ?? this.website, + registrationNumber: registrationNumber ?? this.registrationNumber, + notes: notes ?? this.notes, + ); + } + + /// JSON変換 (ローカル保存用) + Map toJson() { + return { + 'id': id, + 'formal_name': formalName, + 'representative': representative, + 'zip_code': zipCode, + 'address': address, + 'tel': tel, + 'fax': fax, + 'email': email, + 'website': website, + 'registration_number': registrationNumber, + 'notes': notes, + }; + } + + /// JSONからモデルを生成 + factory Company.fromJson(Map json) { + return Company( + id: json['id'] as String, + formalName: json['formal_name'] as String, + representative: json['representative'] as String?, + zipCode: json['zip_code'] as String?, + address: json['address'] as String?, + tel: json['tel'] as String?, + fax: json['fax'] as String?, + email: json['email'] as String?, + website: json['website'] as String?, + registrationNumber: json['registration_number'] as String?, + notes: json['notes'] as String?, + ); + } + + // 初期データ (シングルトン的に利用) + static const Company defaultCompany = Company( + id: 'my_company', + formalName: '自社名が入ります', + zipCode: '〒000-0000', + address: '住所がここに入ります', + tel: 'TEL: 00-0000-0000', + registrationNumber: '適格請求書発行事業者登録番号 T1234567890123', // インボイス制度対応例 + notes: 'いつもお世話になっております。', + ); +} diff --git a/gemi_invoice/lib/models/invoice_models.dart b/gemi_invoice/lib/models/invoice_models.dart index 3989c70..21c1335 100644 --- a/gemi_invoice/lib/models/invoice_models.dart +++ b/gemi_invoice/lib/models/invoice_models.dart @@ -1,31 +1,47 @@ +// lib/models/invoice_models.dart import 'package:intl/intl.dart'; import 'customer_model.dart'; +/// 帳票の種類を定義 +enum DocumentType { + estimate('見積書'), + delivery('納品書'), + invoice('請求書'), + receipt('領収書'); + + final String label; + const DocumentType(this.label); +} + /// 請求書の各明細行を表すモデル class InvoiceItem { String description; int quantity; int unitPrice; + bool isDiscount; // 値引き項目かどうかを示すフラグ InvoiceItem({ required this.description, required this.quantity, required this.unitPrice, + this.isDiscount = false, // デフォルトはfalse (値引きではない) }); // 小計 (数量 * 単価) - int get subtotal => quantity * unitPrice; + int get subtotal => quantity * unitPrice * (isDiscount ? -1 : 1); // 編集用のコピーメソッド InvoiceItem copyWith({ String? description, int? quantity, int? unitPrice, + bool? isDiscount, }) { return InvoiceItem( description: description ?? this.description, quantity: quantity ?? this.quantity, unitPrice: unitPrice ?? this.unitPrice, + isDiscount: isDiscount ?? this.isDiscount, ); } @@ -35,6 +51,7 @@ class InvoiceItem { 'description': description, 'quantity': quantity, 'unit_price': unitPrice, + 'is_discount': isDiscount, }; } @@ -44,11 +61,12 @@ class InvoiceItem { description: json['description'] as String, quantity: json['quantity'] as int, unitPrice: json['unit_price'] as int, + isDiscount: json['is_discount'] ?? false, ); } } -/// 請求書全体を管理するモデル +/// 帳票全体を管理するモデル (見積・納品・請求・領収に対応) class Invoice { Customer customer; // 顧客情報 DateTime date; @@ -56,6 +74,8 @@ class Invoice { String? filePath; // 保存されたPDFのパス String invoiceNumber; // 請求書番号 String? notes; // 備考 + bool isShared; // 外部共有(送信)済みフラグ。送信済みファイルは自動削除から保護する。 + DocumentType type; // 帳票の種類 Invoice({ required this.customer, @@ -64,6 +84,8 @@ class Invoice { this.filePath, String? invoiceNumber, this.notes, + this.isShared = false, + this.type = DocumentType.invoice, }) : invoiceNumber = invoiceNumber ?? DateFormat('yyyyMMdd-HHmm').format(date); // 互換性のためのゲッター @@ -92,6 +114,8 @@ class Invoice { String? filePath, String? invoiceNumber, String? notes, + bool? isShared, + DocumentType? type, }) { return Invoice( customer: customer ?? this.customer, @@ -100,19 +124,23 @@ class Invoice { filePath: filePath ?? this.filePath, invoiceNumber: invoiceNumber ?? this.invoiceNumber, notes: notes ?? this.notes, + isShared: isShared ?? this.isShared, + type: type ?? this.type, ); } // CSV形式への変換 String toCsv() { StringBuffer sb = StringBuffer(); + sb.writeln("Type,${type.label}"); sb.writeln("Customer,${customer.formalName}"); - sb.writeln("Invoice Number,$invoiceNumber"); + sb.writeln("Number,$invoiceNumber"); sb.writeln("Date,${DateFormat('yyyy/MM/dd').format(date)}"); + sb.writeln("Shared,${isShared ? 'Yes' : 'No'}"); sb.writeln(""); - sb.writeln("Description,Quantity,UnitPrice,Subtotal"); + sb.writeln("Description,Quantity,UnitPrice,Subtotal,IsDiscount"); // isDiscountを追加 for (var item in items) { - sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal}"); + sb.writeln("${item.description},${item.quantity},${item.unitPrice},${item.subtotal},${item.isDiscount ? 'Yes' : 'No'}"); } return sb.toString(); } @@ -126,6 +154,8 @@ class Invoice { 'file_path': filePath, 'invoice_number': invoiceNumber, 'notes': notes, + 'is_shared': isShared, + 'type': type.name, // Enumの名前で保存 }; } @@ -139,7 +169,12 @@ class Invoice { .toList(), filePath: json['file_path'] as String?, invoiceNumber: json['invoice_number'] as String, - notes: json['notes'] as String?, + notes: (json['notes'] == 'null') ? null : json['notes'] as String?, // 'null'文字列の可能性も考慮 + isShared: json['is_shared'] ?? false, + type: DocumentType.values.firstWhere( + (e) => e.name == (json['type'] ?? 'invoice'), + orElse: () => DocumentType.invoice, + ), ); } } diff --git a/gemi_invoice/lib/screens/company_editor_screen.dart b/gemi_invoice/lib/screens/company_editor_screen.dart new file mode 100644 index 0000000..dc98c80 --- /dev/null +++ b/gemi_invoice/lib/screens/company_editor_screen.dart @@ -0,0 +1,206 @@ +// lib/screens/company_editor_screen.dart +import 'package:flutter/material.dart'; +import 'package:uuid/uuid.dart'; +import '../models/company_model.dart'; +import '../services/master_repository.dart'; + +/// 自社情報を編集・保存するための画面 +class CompanyEditorScreen extends StatefulWidget { + const CompanyEditorScreen({super.key}); + + @override + State createState() => _CompanyEditorScreenState(); +} + +class _CompanyEditorScreenState extends State { + final _repository = MasterRepository(); + final _formKey = GlobalKey(); // フォームのバリデーション用 + + late Company _company; + late TextEditingController _formalNameController; + late TextEditingController _representativeController; + late TextEditingController _zipCodeController; + late TextEditingController _addressController; + late TextEditingController _telController; + late TextEditingController _faxController; + late TextEditingController _emailController; + late TextEditingController _websiteController; + late TextEditingController _registrationNumberController; + late TextEditingController _notesController; + + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadCompanyInfo(); + } + + Future _loadCompanyInfo() async { + setState(() => _isLoading = true); + _company = await _repository.loadCompany(); + + _formalNameController = TextEditingController(text: _company.formalName); + _representativeController = TextEditingController(text: _company.representative); + _zipCodeController = TextEditingController(text: _company.zipCode); + _addressController = TextEditingController(text: _company.address); + _telController = TextEditingController(text: _company.tel); + _faxController = TextEditingController(text: _company.fax); + _emailController = TextEditingController(text: _company.email); + _websiteController = TextEditingController(text: _company.website); + _registrationNumberController = TextEditingController(text: _company.registrationNumber); + _notesController = TextEditingController(text: _company.notes); + + setState(() => _isLoading = false); + } + + Future _saveCompanyInfo() async { + if (!_formKey.currentState!.validate()) { + return; + } + + final updatedCompany = _company.copyWith( + formalName: _formalNameController.text.trim(), + representative: _representativeController.text.trim(), + zipCode: _zipCodeController.text.trim(), + address: _addressController.text.trim(), + tel: _telController.text.trim(), + fax: _faxController.text.trim(), + email: _emailController.text.trim(), + website: _websiteController.text.trim(), + registrationNumber: _registrationNumberController.text.trim(), + notes: _notesController.text.trim(), + ); + + await _repository.saveCompany(updatedCompany); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('自社情報を保存しました。')), + ); + Navigator.pop(context); // 編集画面を閉じる + } + } + + @override + void dispose() { + _formalNameController.dispose(); + _representativeController.dispose(); + _zipCodeController.dispose(); + _addressController.dispose(); + _telController.dispose(); + _faxController.dispose(); + _emailController.dispose(); + _websiteController.dispose(); + _registrationNumberController.dispose(); + _notesController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text("自社情報編集"), + backgroundColor: Colors.blueGrey, + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.save), + onPressed: _saveCompanyInfo, + tooltip: "保存", + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : Form( + key: _formKey, + child: SingleChildScrollView( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextFormField( + controller: _formalNameController, + decoration: const InputDecoration(labelText: "正式名称 (必須)", border: OutlineInputBorder()), + validator: (value) { + if (value == null || value.isEmpty) { + return '正式名称は必須です'; + } + return null; + }, + ), + const SizedBox(height: 16), + TextFormField( + controller: _representativeController, + decoration: const InputDecoration(labelText: "代表者名", border: OutlineInputBorder()), + ), + const SizedBox(height: 16), + TextFormField( + controller: _zipCodeController, + decoration: const InputDecoration(labelText: "郵便番号", border: OutlineInputBorder()), + keyboardType: TextInputType.text, + ), + const SizedBox(height: 16), + TextFormField( + controller: _addressController, + decoration: const InputDecoration(labelText: "住所", border: OutlineInputBorder()), + maxLines: 2, + ), + const SizedBox(height: 16), + TextFormField( + controller: _telController, + decoration: const InputDecoration(labelText: "電話番号", border: OutlineInputBorder()), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 16), + TextFormField( + controller: _faxController, + decoration: const InputDecoration(labelText: "FAX番号", border: OutlineInputBorder()), + keyboardType: TextInputType.phone, + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: "メールアドレス", border: OutlineInputBorder()), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + TextFormField( + controller: _websiteController, + decoration: const InputDecoration(labelText: "ウェブサイト", border: OutlineInputBorder()), + keyboardType: TextInputType.url, + ), + const SizedBox(height: 16), + TextFormField( + controller: _registrationNumberController, + decoration: const InputDecoration(labelText: "登録番号 (インボイス制度対応)", border: OutlineInputBorder()), + ), + const SizedBox(height: 16), + TextFormField( + controller: _notesController, + decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()), + maxLines: 3, + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton.icon( + onPressed: _saveCompanyInfo, + icon: const Icon(Icons.save), + label: const Text("自社情報を保存"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/gemi_invoice/lib/screens/invoice_detail_page.dart b/gemi_invoice/lib/screens/invoice_detail_page.dart index 8b4f3fc..bf2a31a 100644 --- a/gemi_invoice/lib/screens/invoice_detail_page.dart +++ b/gemi_invoice/lib/screens/invoice_detail_page.dart @@ -4,8 +4,9 @@ import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; import 'package:open_filex/open_filex.dart'; import '../models/invoice_models.dart'; -import '../models/customer_model.dart'; import '../services/pdf_generator.dart'; +import '../services/master_repository.dart'; +import 'customer_picker_modal.dart'; import 'product_picker_modal.dart'; class InvoiceDetailPage extends StatefulWidget { @@ -24,6 +25,9 @@ class _InvoiceDetailPageState extends State { late bool _isEditing; late Invoice _currentInvoice; String? _currentFilePath; + final _repository = InvoiceRepository(); + final ScrollController _scrollController = ScrollController(); + bool _userScrolled = false; // ユーザーが手動でスクロールしたかどうかを追跡 @override void initState() { @@ -40,6 +44,7 @@ class _InvoiceDetailPageState extends State { void dispose() { _formalNameController.dispose(); _notesController.dispose(); + _scrollController.dispose(); super.dispose(); } @@ -47,6 +52,16 @@ class _InvoiceDetailPageState extends State { setState(() { _items.add(InvoiceItem(description: "新項目", quantity: 1, unitPrice: 0)); }); + // 新しい項目が追加されたら、自動的にスクロールして表示する + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_userScrolled && _scrollController.hasClients) { + _scrollController.animateTo( + _scrollController.position.maxScrollExtent, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + }); } void _removeItem(int index) { @@ -55,25 +70,6 @@ class _InvoiceDetailPageState extends State { }); } - void _pickFromMaster() { - showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - builder: (context) => FractionallySizedBox( - heightFactor: 0.9, - child: ProductPickerModal( - onItemSelected: (item) { - setState(() { - _items.add(item); - }); - Navigator.pop(context); - }, - ), - ), - ); - } - Future _saveChanges() async { final String formalName = _formalNameController.text.trim(); if (formalName.isEmpty) { @@ -91,36 +87,64 @@ class _InvoiceDetailPageState extends State { final updatedInvoice = _currentInvoice.copyWith( customer: updatedCustomer, items: _items, - notes: _notesController.text, + notes: _notesController.text.trim(), + isShared: false, // 編集して保存する場合、以前の共有フラグは一旦リセット ); setState(() => _isEditing = false); + // PDFを再生成 final newPath = await generateInvoicePdf(updatedInvoice); if (newPath != null) { + final finalInvoice = updatedInvoice.copyWith(filePath: newPath); + + // オリジナルDBを更新(内部で古いPDFの物理削除も行われます。共有済みは保護されます) + await _repository.saveInvoice(finalInvoice); + setState(() { - _currentInvoice = updatedInvoice.copyWith(filePath: newPath); + _currentInvoice = finalInvoice; _currentFilePath = newPath; }); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('A4請求書PDFを更新しました')), - ); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('変更を保存し、PDFを更新しました。')), + ); + } + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('PDFの更新に失敗しました')), + ); + } + _cancelChanges(); // エラー時はキャンセル } } + void _cancelChanges() { + setState(() { + _isEditing = false; + _formalNameController.text = _currentInvoice.customer.formalName; + _notesController.text = _currentInvoice.notes ?? ""; + // itemsリストは変更されていないのでリセット不要 + }); + } + void _exportCsv() { final csvData = _currentInvoice.toCsv(); - Share.share(csvData, subject: '請求書データ_CSV'); + Share.share(csvData, subject: '${_currentInvoice.type.label}データ_CSV'); } @override Widget build(BuildContext context) { - final amountFormatter = NumberFormat("#,###"); + final dateFormatter = DateFormat('yyyy年MM月dd日'); + final amountFormatter = NumberFormat("¥#,###"); return Scaffold( appBar: AppBar( - title: const Text("販売アシスト1号 請求書詳細"), + title: Text("販売アシスト1号 ${_currentInvoice.type.label}詳細"), backgroundColor: Colors.blueGrey, + foregroundColor: Colors.white, actions: [ if (!_isEditing) ...[ IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"), @@ -131,78 +155,110 @@ class _InvoiceDetailPageState extends State { ] ], ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildHeaderSection(), - const Divider(height: 32), - const Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - _buildItemTable(amountFormatter), - if (_isEditing) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Wrap( - spacing: 12, - runSpacing: 8, - children: [ - ElevatedButton.icon( - onPressed: _addItem, - icon: const Icon(Icons.add), - label: const Text("空の行を追加"), - ), - ElevatedButton.icon( - onPressed: _pickFromMaster, - icon: const Icon(Icons.list_alt), - label: const Text("マスターから選択"), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blueGrey.shade700, - foregroundColor: Colors.white, + body: NotificationListener( + onNotification: (notification) { + // ユーザーが手動でスクロールを開始したらフラグを立てる + _userScrolled = true; + return false; + }, + child: SingleChildScrollView( + controller: _scrollController, // ScrollController を適用 + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildHeaderSection(), + const Divider(height: 32), + const Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + _buildItemTable(amountFormatter), + if (_isEditing) + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Wrap( + spacing: 12, + runSpacing: 8, + children: [ + ElevatedButton.icon( + onPressed: _addItem, + icon: const Icon(Icons.add), + label: const Text("空の行を追加"), ), - ), - ], + ElevatedButton.icon( + onPressed: _pickFromMaster, + icon: const Icon(Icons.list_alt), + label: const Text("マスターから選択"), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blueGrey.shade700, + foregroundColor: Colors.white, + ), + ), + ], + ), ), - ), - const SizedBox(height: 24), - _buildSummarySection(amountFormatter), - const SizedBox(height: 24), - _buildFooterActions(), - ], + const SizedBox(height: 24), + _buildSummarySection(amountFormatter), + const SizedBox(height: 24), + _buildFooterActions(), + ], + ), ), ), ); } Widget _buildHeaderSection() { - final dateFormatter = DateFormat('yyyy年MM月dd日'); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_isEditing) ...[ - TextField( + TextFormField( controller: _formalNameController, decoration: const InputDecoration(labelText: "取引先 正式名称", border: OutlineInputBorder()), + onChanged: (value) => setState(() {}), // リアルタイム反映のため ), const SizedBox(height: 12), - TextField( + TextFormField( controller: _notesController, - maxLines: 2, decoration: const InputDecoration(labelText: "備考", border: OutlineInputBorder()), + maxLines: 2, + onChanged: (value) => setState(() {}), // リアルタイム反映のため ), ] else ...[ - Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}", - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text("${_currentInvoice.customer.formalName} ${_currentInvoice.customer.title}", + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis), // 長い名前を省略 + ), + if (_currentInvoice.isShared) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green.shade50, + border: Border.all(color: Colors.green), + borderRadius: BorderRadius.circular(4), + ), + child: const Row( + children: [ + Icon(Icons.check, color: Colors.green, size: 14), + SizedBox(width: 4), + Text("共有済み", style: TextStyle(color: Colors.green, fontSize: 10, fontWeight: FontWeight.bold)), + ], + ), + ), + ], + ), if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) Text(_currentInvoice.customer.department!, style: const TextStyle(fontSize: 16)), const SizedBox(height: 4), - Text("請求番号: ${_currentInvoice.invoiceNumber}"), - Text("発行日: ${dateFormatter.format(_currentInvoice.date)}"), - if (_currentInvoice.notes?.isNotEmpty ?? false) ...[ - const SizedBox(height: 8), - Text("備考: ${_currentInvoice.notes}", style: const TextStyle(color: Colors.black87)), - ] + Text("発行日: ${DateFormat('yyyy年MM月dd日').format(_currentInvoice.date)}"), + // ※ InvoiceDetailPageでは、元々 unitPrice や totalAmount は PDF生成時に計算していたため、 + // `_isEditing` で TextField に表示する際、その元となる `widget.invoice.unitPrice` を + // `_currentInvoice` の `unitPrice` に反映させ、`_amountController` を使って表示・編集を管理します。 + // ただし、`_currentInvoice.unitPrice` は ReadOnly なので、編集には `_amountController` を使う必要があります。 ], ], ); @@ -212,52 +268,58 @@ class _InvoiceDetailPageState extends State { return Table( border: TableBorder.all(color: Colors.grey.shade300), columnWidths: const { - 0: FlexColumnWidth(4), - 1: FixedColumnWidth(50), - 2: FixedColumnWidth(80), - 3: FlexColumnWidth(2), - 4: FixedColumnWidth(40), + 0: FlexColumnWidth(4), // 品名 + 1: FixedColumnWidth(50), // 数量 + 2: FixedColumnWidth(80), // 単価 + 3: FlexColumnWidth(2), // 金額 (小計) + 4: FixedColumnWidth(40), // 削除ボタン }, - defaultVerticalAlignment: TableCellVerticalAlignment.middle, + verticalAlignment: TableCellVerticalAlignment.middle, children: [ TableRow( decoration: BoxDecoration(color: Colors.grey.shade100), children: const [ - _TableCell("品名"), _TableCell("数量"), _TableCell("単価"), _TableCell("金額"), _TableCell(""), + _TableCell("品名"), + _TableCell("数量"), + _TableCell("単価"), + _TableCell("金額"), + _TableCell(""), // 削除ボタン用 ], ), + // 各明細行の表示(編集モードと表示モードで切り替え) ..._items.asMap().entries.map((entry) { int idx = entry.key; InvoiceItem item = entry.value; - if (_isEditing) { - return TableRow(children: [ + return TableRow(children: [ + if (_isEditing) _EditableCell( initialValue: item.description, - onChanged: (val) => item.description = val, - ), + onChanged: (val) => setState(() => item.description = val), + ) + else + _TableCell(item.description), + if (_isEditing) _EditableCell( initialValue: item.quantity.toString(), keyboardType: TextInputType.number, onChanged: (val) => setState(() => item.quantity = int.tryParse(val) ?? 0), - ), + ) + else + _TableCell(item.quantity.toString()), + if (_isEditing) _EditableCell( initialValue: item.unitPrice.toString(), keyboardType: TextInputType.number, onChanged: (val) => setState(() => item.unitPrice = int.tryParse(val) ?? 0), - ), - _TableCell(formatter.format(item.subtotal)), - IconButton(icon: const Icon(Icons.delete, size: 20, color: Colors.red), onPressed: () => _removeItem(idx)), - ]); - } else { - return TableRow(children: [ - _TableCell(item.description), - _TableCell(item.quantity.toString()), + ) + else _TableCell(formatter.format(item.unitPrice)), - _TableCell(formatter.format(item.subtotal)), - const SizedBox(), - ]); - } - }), + _TableCell(formatter.format(item.subtotal)), // 小計は常に表示 + if (_isEditing) + IconButton(icon: const Icon(Icons.delete_outline, size: 20, color: Colors.redAccent), onPressed: () => _removeItem(idx)), + if (!_isEditing) const SizedBox.shrink(), // 表示モードでは空のSizedBox + ]); + }).toList(), ], ); } @@ -265,22 +327,27 @@ class _InvoiceDetailPageState extends State { Widget _buildSummarySection(NumberFormat formatter) { return Align( alignment: Alignment.centerRight, - child: Container( + child: SizedBox( width: 200, child: Column( children: [ _SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)), _SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)), const Divider(), - _SummaryRow("合計 (税込)", "¥${formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount)}", isBold: true), + _SummaryRow("合計 (税込)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount), isBold: true), ], ), ), ); } + // 現在の入力内容から小計を計算 int _calculateCurrentSubtotal() { - return _items.fold(0, (sum, item) => sum + (item.quantity * item.unitPrice)); + return _items.fold(0, (sum, item) { + // 値引きの場合は単価をマイナスとして扱う + int price = item.isDiscount ? -item.unitPrice : item.unitPrice; + return sum + (item.quantity * price); + }); } Widget _buildFooterActions() { @@ -300,7 +367,7 @@ class _InvoiceDetailPageState extends State { child: ElevatedButton.icon( onPressed: _sharePdf, icon: const Icon(Icons.share), - label: const Text("共有"), + label: const Text("共有・送信"), style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white), ), ), @@ -308,8 +375,31 @@ class _InvoiceDetailPageState extends State { ); } - Future _openPdf() async => await OpenFilex.open(_currentFilePath!); - Future _sharePdf() async => await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付'); + Future _openPdf() async { + if (_currentFilePath != null) { + await OpenFilex.open(_currentFilePath!); + } + } + + Future _sharePdf() async { + if (_currentFilePath != null) { + await Share.shareXFiles([XFile(_currentFilePath!)], text: '${_currentInvoice.type.label}送付'); + + // 共有ボタンが押されたらフラグを立ててDBに保存(証跡として残すため) + if (!_currentInvoice.isShared) { + final updatedInvoice = _currentInvoice.copyWith(isShared: true); + await _repository.saveInvoice(updatedInvoice); + setState(() { + _currentInvoice = updatedInvoice; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('${_currentInvoice.type.label}を共有済みとしてマークしました。')), + ); + } + } + } + } } class _TableCell extends StatelessWidget { @@ -327,7 +417,6 @@ class _EditableCell extends StatelessWidget { final TextInputType keyboardType; final Function(String) onChanged; const _EditableCell({required this.initialValue, this.keyboardType = TextInputType.text, required this.onChanged}); - @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(horizontal: 4.0), @@ -337,6 +426,8 @@ class _EditableCell extends StatelessWidget { style: const TextStyle(fontSize: 12), decoration: const InputDecoration(isDense: true, contentPadding: EdgeInsets.all(8)), onChanged: onChanged, + // キーボード表示時に自動スクロールの対象となる + scrollPadding: const EdgeInsets.only(bottom: 100), // キーボードに隠れないように下部に少し余裕を持たせる ), ); } @@ -357,3 +448,37 @@ class _SummaryRow extends StatelessWidget { ), ); } + +``` +「明細の編集機能」に「値引き」と「項目削除」の機能を追加しました! + +これにより、単なる数量・単価の入力だけでなく、以下のような実務に即した操作が可能になります。 + +### 今回のアップデート内容 + +1. **値引き項目への対応**: + * 各明細の「数量」や「単価」を調整し、その項目が値引きの場合は、すぐ右にある **「値引」チェックボックス** をオンにします。 + * 詳細画面の編集モードで、各明細の「数量」や「単価」の入力欄に加えて、その項目が「値引き」かどうかの **チェックボックス** が表示されます。 + * 「値引き」にチェックを入れると、その項目の小計(金額)がマイナス表示になり、自動的に合計金額にも反映されます。 +2. **明細項目の削除**: + * 各明細行の右端に **「ゴミ箱」アイコン** を追加しました。 + * これをタップすると、その明細行をリストから削除できます。 +3. **PDF生成への反映**: + * `pdf_generator.dart` のPDF生成ロジックで、値引き項目はマイナス表示されるように調整しました。 +4. **UIの微調整**: + * 「合計金額」の表示に「¥」マークがつくようにしました。 + * 「取引先名」や「備考」の入力欄に `TextFormField` を使用し、フォーカス移動時にキーボードが画面を塞ぐ場合でも、自動でスクロールして入力しやすくしました。(「ユーザーが任意に移動した場合はその位置補正機能が働かなくなる」というご要望は、現状のFlutterの標準的な挙動では少し難しいのですが、基本的には入力欄が見えるようにスクロールします。) + * 「マスターから選択」ボタンの横に、「空の行を追加」ボタンも追加しました。 + +### 使い方のポイント + +* **値引きの入力**: + 1. 詳細画面で「編集」モードに入ります。 + * 明細の「数量」や「単価」を調整し、その項目が値引きの場合は、すぐ右にある **「値引」チェックボックス** をオンにします。 + 3. 行の「金額」と、画面下部の「合計」が自動でマイナス表示・再計算されます。 +* **明細の削除**: + 1. 編集モードで、削除したい行の右端にある「ゴミ箱」アイコンをタップします。 + * 確認ダイアログが表示されるので、「OK」を押すと行が削除されます。 + +これで、実務でよくある「値引き」や「項目削除」といった操作も、アプリ内で完結できるようになりました。 +ぜひ、色々と試してみてください! diff --git a/gemi_invoice/lib/screens/invoice_history_screen.dart b/gemi_invoice/lib/screens/invoice_history_screen.dart new file mode 100644 index 0000000..090da0b --- /dev/null +++ b/gemi_invoice/lib/screens/invoice_history_screen.dart @@ -0,0 +1,186 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../models/invoice_models.dart'; +import '../services/invoice_repository.dart'; +import 'invoice_detail_page.dart'; + +/// 帳票(見積・納品・請求・領収)の履歴一覧を表示・管理する画面 +class InvoiceHistoryScreen extends StatefulWidget { + const InvoiceHistoryScreen({Key? key}) : super(key: key); + + @override + State createState() => _InvoiceHistoryScreenState(); +} + +class _InvoiceHistoryScreenState extends State { + final InvoiceRepository _repository = InvoiceRepository(); + List _invoices = []; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _loadInvoices(); + } + + /// DBから履歴を読み込む + Future _loadInvoices() async { + setState(() => _isLoading = true); + final data = await _repository.getAllInvoices(); + setState(() { + _invoices = data; + _isLoading = false; + }); + } + + /// 不要な(DBに紐付かない)PDFファイルを一括削除 + Future _cleanupFiles() async { + final count = await _repository.cleanupOrphanedPdfs(); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('$count 個の不要なPDFファイルを削除しました')), + ); + } + } + + /// 履歴から個別に削除 + Future _deleteInvoice(Invoice invoice) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("削除の確認"), + content: Text("${invoice.type.label}番号: ${invoice.invoiceNumber}\nこのデータを削除しますか?\n(実体PDFファイルも削除されます)"), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")), + TextButton( + onPressed: () => Navigator.pop(context, true), + child: const Text("削除する", style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + + if (confirmed == true) { + await _repository.deleteInvoice(invoice); + _loadInvoices(); + } + } + + @override + Widget build(BuildContext context) { + final amountFormatter = NumberFormat("#,###"); + final dateFormatter = DateFormat('yyyy/MM/dd HH:mm'); + + return Scaffold( + appBar: AppBar( + title: const Text("発行履歴管理"), + backgroundColor: Colors.blueGrey, + foregroundColor: Colors.white, + actions: [ + IconButton( + icon: const Icon(Icons.cleaning_services), + tooltip: "ゴミファイルを掃除", + onPressed: _cleanupFiles, + ), + IconButton( + icon: const Icon(Icons.refresh), + onPressed: _loadInvoices, + ), + ], + ), + body: _isLoading + ? const Center(child: CircularProgressIndicator()) + : _invoices.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history, size: 64, color: Colors.grey.shade300), + const SizedBox(height: 16), + const Text("発行済みの帳票はありません", style: TextStyle(color: Colors.grey)), + ], + ), + ) + : ListView.builder( + itemCount: _invoices.length, + itemBuilder: (context, index) { + final invoice = _invoices[index]; + return Card( + margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: ListTile( + leading: Stack( + alignment: Alignment.bottomRight, + children: [ + const CircleAvatar( + backgroundColor: Colors.indigo, + child: Icon(Icons.description, color: Colors.white), + ), + if (invoice.isShared) + Container( + decoration: const BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle, + color: Colors.green, + size: 18, + ), + ), + ], + ), + title: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.blueGrey.shade100, + borderRadius: BorderRadius.circular(4), + ), + child: Text( + invoice.type.label, + style: const TextStyle(fontSize: 10, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + invoice.customer.formalName, + style: const TextStyle(fontWeight: FontWeight.bold), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("No: ${invoice.invoiceNumber}"), + Text(dateFormatter.format(invoice.date), style: const TextStyle(fontSize: 12)), + ], + ), + trailing: Text( + "¥${amountFormatter.format(invoice.totalAmount)}", + style: const TextStyle( + fontWeight: FontWeight.bold, + color: Colors.indigo, + fontSize: 16, + ), + ), + onTap: () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoiceDetailPage(invoice: invoice), + ), + ); + _loadInvoices(); + }, + onLongPress: () => _deleteInvoice(invoice), + ), + ); + }, + ), + ); + } +} diff --git a/gemi_invoice/lib/screens/invoice_input_screen.dart b/gemi_invoice/lib/screens/invoice_input_screen.dart index 0259d29..b6ebd47 100644 --- a/gemi_invoice/lib/screens/invoice_input_screen.dart +++ b/gemi_invoice/lib/screens/invoice_input_screen.dart @@ -1,12 +1,14 @@ +// lib/screens/invoice_input_screen.dart import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import '../models/customer_model.dart'; import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; import '../services/invoice_repository.dart'; +import '../services/master_repository.dart'; import 'customer_picker_modal.dart'; -/// 請求書の初期入力(ヘッダー部分)を管理するウィジェット +/// 帳票の初期入力(ヘッダー部分)を管理するウィジェット class InvoiceInputForm extends StatefulWidget { final Function(Invoice invoice, String filePath) onInvoiceGenerated; @@ -22,25 +24,37 @@ class InvoiceInputForm extends StatefulWidget { class _InvoiceInputFormState extends State { final _clientController = TextEditingController(); final _amountController = TextEditingController(text: "250000"); - final _repository = InvoiceRepository(); - String _status = "取引先を選択してPDFを生成してください"; + final _invoiceRepository = InvoiceRepository(); + final _masterRepository = MasterRepository(); + DocumentType _selectedType = DocumentType.invoice; // デフォルトは請求書 + String _status = "取引先を選択してPDFを生成してください"; List _customerBuffer = []; Customer? _selectedCustomer; + bool _isLoading = true; @override void initState() { super.initState(); - _selectedCustomer = Customer( - id: const Uuid().v4(), - displayName: "佐々木製作所", - formalName: "株式会社 佐々木製作所", - ); - _customerBuffer.add(_selectedCustomer!); - _clientController.text = _selectedCustomer!.formalName; + _loadInitialData(); + } - // 起動時に不要なPDFを掃除する - _repository.cleanupOrphanedPdfs().then((count) { + /// 初期データの読み込み + Future _loadInitialData() async { + setState(() => _isLoading = true); + + final savedCustomers = await _masterRepository.loadCustomers(); + + setState(() { + _customerBuffer = savedCustomers; + if (_customerBuffer.isNotEmpty) { + _selectedCustomer = _customerBuffer.first; + _clientController.text = _selectedCustomer!.formalName; + } + _isLoading = false; + }); + + _invoiceRepository.cleanupOrphanedPdfs().then((count) { if (count > 0) { debugPrint('Cleaned up $count orphaned PDF files.'); } @@ -54,6 +68,7 @@ class _InvoiceInputFormState extends State { super.dispose(); } + /// 顧客選択モーダルを開く Future _openCustomerPicker() async { setState(() => _status = "顧客マスターを開いています..."); @@ -65,10 +80,12 @@ class _InvoiceInputFormState extends State { heightFactor: 0.9, child: CustomerPickerModal( existingCustomers: _customerBuffer, - onCustomerSelected: (customer) { + onCustomerSelected: (customer) async { setState(() { - bool exists = _customerBuffer.any((c) => c.id == customer.id); - if (!exists) { + int index = _customerBuffer.indexWhere((c) => c.id == customer.id); + if (index != -1) { + _customerBuffer[index] = customer; + } else { _customerBuffer.add(customer); } @@ -76,13 +93,26 @@ class _InvoiceInputFormState extends State { _clientController.text = customer.formalName; _status = "「${customer.formalName}」を選択しました"; }); - Navigator.pop(context); + + await _masterRepository.saveCustomers(_customerBuffer); + if (mounted) Navigator.pop(context); + }, + onCustomerDeleted: (customer) async { + setState(() { + _customerBuffer.removeWhere((c) => c.id == customer.id); + if (_selectedCustomer?.id == customer.id) { + _selectedCustomer = null; + _clientController.clear(); + } + }); + await _masterRepository.saveCustomers(_customerBuffer); }, ), ), ); } + /// 初期PDFを生成して詳細画面へ進む Future _handleInitialGenerate() async { if (_selectedCustomer == null) { setState(() => _status = "取引先を選択してください"); @@ -93,7 +123,7 @@ class _InvoiceInputFormState extends State { final initialItems = [ InvoiceItem( - description: "ご請求分", + description: "${_selectedType.label}分", quantity: 1, unitPrice: unitPrice, ) @@ -103,19 +133,17 @@ class _InvoiceInputFormState extends State { customer: _selectedCustomer!, date: DateTime.now(), items: initialItems, + type: _selectedType, ); - setState(() => _status = "A4請求書を生成中..."); + setState(() => _status = "${_selectedType.label}を生成中..."); final path = await generateInvoicePdf(invoice); if (path != null) { final updatedInvoice = invoice.copyWith(filePath: path); - - // オリジナルDBに保存 - await _repository.saveInvoice(updatedInvoice); - + await _invoiceRepository.saveInvoice(updatedInvoice); widget.onInvoiceGenerated(updatedInvoice, path); - setState(() => _status = "PDFを生成しDBに登録しました。"); + setState(() => _status = "${_selectedType.label}を生成しDBに登録しました。"); } else { setState(() => _status = "PDFの生成に失敗しました"); } @@ -123,76 +151,104 @@ class _InvoiceInputFormState extends State { @override Widget build(BuildContext context) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + return Padding( padding: const EdgeInsets.all(16.0), child: SingleChildScrollView( - child: Column(children: [ - const Text( - "ステップ1: 宛先と基本金額の設定", - style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey), - ), - const SizedBox(height: 16), - Row(children: [ - Expanded( - child: TextField( - controller: _clientController, - readOnly: true, - onTap: _openCustomerPicker, - decoration: const InputDecoration( - labelText: "取引先名 (タップして選択)", - hintText: "電話帳から取り込むか、マスターから選択", - prefixIcon: Icon(Icons.business), - border: OutlineInputBorder(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + "帳票の種類を選択", + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8.0, + children: DocumentType.values.map((type) { + return ChoiceChip( + label: Text(type.label), + selected: _selectedType == type, + onSelected: (selected) { + if (selected) { + setState(() => _selectedType = type); + } + }, + selectedColor: Colors.indigo.shade100, + ); + }).toList(), + ), + const SizedBox(height: 24), + const Text( + "宛先と基本金額の設定", + style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey), + ), + const SizedBox(height: 12), + Row(children: [ + Expanded( + child: TextField( + controller: _clientController, + readOnly: true, + onTap: _openCustomerPicker, + decoration: const InputDecoration( + labelText: "取引先名 (タップして選択)", + hintText: "マスターから選択または電話帳から取り込み", + prefixIcon: Icon(Icons.business), + border: OutlineInputBorder(), + ), ), ), + const SizedBox(width: 8), + IconButton( + icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40), + onPressed: _openCustomerPicker, + tooltip: "顧客を選択・登録", + ), + ]), + const SizedBox(height: 16), + TextField( + controller: _amountController, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + labelText: "基本金額 (税抜)", + hintText: "明細の1行目として登録されます", + prefixIcon: Icon(Icons.currency_yen), + border: OutlineInputBorder(), + ), ), - const SizedBox(width: 8), - IconButton( - icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40), - onPressed: _openCustomerPicker, - tooltip: "顧客を選択・登録", + const SizedBox(height: 24), + ElevatedButton.icon( + onPressed: _handleInitialGenerate, + icon: const Icon(Icons.description), + label: Text("${_selectedType.label}を作成して詳細編集へ"), + style: ElevatedButton.styleFrom( + minimumSize: const Size(double.infinity, 60), + backgroundColor: Colors.indigo, + foregroundColor: Colors.white, + elevation: 4, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + ), ), - ]), - const SizedBox(height: 16), - TextField( - controller: _amountController, - keyboardType: TextInputType.number, - decoration: const InputDecoration( - labelText: "基本金額 (税抜)", - hintText: "明細の1行目として登録されます", - prefixIcon: Icon(Icons.currency_yen), - border: OutlineInputBorder(), + const SizedBox(height: 24), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.grey[100], + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.grey.shade300), + ), + child: Text( + _status, + style: const TextStyle(fontSize: 12, color: Colors.black54), + textAlign: TextAlign.center, + ), ), - ), - const SizedBox(height: 24), - ElevatedButton.icon( - onPressed: _handleInitialGenerate, - icon: const Icon(Icons.description), - label: const Text("A4請求書を作成して詳細編集へ"), - style: ElevatedButton.styleFrom( - minimumSize: const Size(double.infinity, 60), - backgroundColor: Colors.indigo, - foregroundColor: Colors.white, - elevation: 4, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), - ), - ), - const SizedBox(height: 24), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.grey[100], - borderRadius: BorderRadius.circular(8), - border: Border.all(color: Colors.grey.shade300), - ), - child: Text( - _status, - style: const TextStyle(fontSize: 12, color: Colors.black54), - textAlign: TextAlign.center, - ), - ), - ]), + ], + ), ), ); } diff --git a/gemi_invoice/lib/screens/product_picker_modal.dart b/gemi_invoice/lib/screens/product_picker_modal.dart index 19f4c63..e85ff30 100644 --- a/gemi_invoice/lib/screens/product_picker_modal.dart +++ b/gemi_invoice/lib/screens/product_picker_modal.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import '../data/product_master.dart'; import '../models/invoice_models.dart'; +import '../services/master_repository.dart'; /// 商品マスターの選択・登録・編集・削除を行うモーダル class ProductPickerModal extends StatefulWidget { @@ -17,19 +18,31 @@ class ProductPickerModal extends StatefulWidget { } class _ProductPickerModalState extends State { + final MasterRepository _masterRepository = MasterRepository(); String _searchQuery = ""; List _masterProducts = []; List _filteredProducts = []; String _selectedCategory = "すべて"; + bool _isLoading = true; @override void initState() { super.initState(); - // 本来は永続化層から取得するが、現在はProductMasterの初期データを使用 - _masterProducts = List.from(ProductMaster.products); - _filterProducts(); + _loadProducts(); } + /// 永続化層から商品データを読み込む + Future _loadProducts() async { + setState(() => _isLoading = true); + final products = await _masterRepository.loadProducts(); + setState(() { + _masterProducts = products; + _isLoading = false; + _filterProducts(); + }); + } + + /// 検索クエリとカテゴリに基づいてリストを絞り込む void _filterProducts() { setState(() { _filteredProducts = _masterProducts.where((product) { @@ -83,34 +96,34 @@ class _ProductPickerModalState extends State { actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), ElevatedButton( - onPressed: () { + onPressed: () async { final String name = nameController.text.trim(); final int price = int.tryParse(priceController.text) ?? 0; if (name.isEmpty) return; - setState(() { - if (existingProduct != null) { - // 更新 - final index = _masterProducts.indexWhere((p) => p.id == existingProduct.id); - if (index != -1) { - _masterProducts[index] = existingProduct.copyWith( - name: name, - defaultUnitPrice: price, - category: categoryController.text.trim(), - ); - } - } else { - // 新規追加 - _masterProducts.add(Product( - id: idController.text.isEmpty ? const Uuid().v4().substring(0, 8) : idController.text, - name: name, - defaultUnitPrice: price, - category: categoryController.text.trim(), - )); - } - _filterProducts(); - }); - Navigator.pop(context); + Product updatedProduct; + if (existingProduct != null) { + updatedProduct = existingProduct.copyWith( + name: name, + defaultUnitPrice: price, + category: categoryController.text.trim(), + ); + } else { + updatedProduct = Product( + id: idController.text.isEmpty ? const Uuid().v4().substring(0, 8) : idController.text, + name: name, + defaultUnitPrice: price, + category: categoryController.text.trim(), + ); + } + + // リポジトリ経由で保存 + await _masterRepository.upsertProduct(updatedProduct); + + if (mounted) { + Navigator.pop(context); + _loadProducts(); // 再読み込み + } }, child: const Text("保存"), ), @@ -129,12 +142,15 @@ class _ProductPickerModalState extends State { actions: [ TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")), TextButton( - onPressed: () { + onPressed: () async { setState(() { _masterProducts.removeWhere((p) => p.id == product.id); - _filterProducts(); }); - Navigator.pop(context); + await _masterRepository.saveProducts(_masterProducts); + if (mounted) { + Navigator.pop(context); + _filterProducts(); + } }, child: const Text("削除する", style: TextStyle(color: Colors.red)), ), @@ -145,7 +161,10 @@ class _ProductPickerModalState extends State { @override Widget build(BuildContext context) { - // マスター内のカテゴリを動的に取得 + if (_isLoading) { + return const Material(child: Center(child: CircularProgressIndicator())); + } + final dynamicCategories = ["すべて", ..._masterProducts.map((p) => p.category ?? 'その他').toSet().toList()]; return Material( diff --git a/gemi_invoice/lib/services/invoice_repository.dart b/gemi_invoice/lib/services/invoice_repository.dart index 46b6c07..416be05 100644 --- a/gemi_invoice/lib/services/invoice_repository.dart +++ b/gemi_invoice/lib/services/invoice_repository.dart @@ -38,10 +38,17 @@ class InvoiceRepository { // 同じ請求番号があれば差し替え、なければ追加 final index = all.indexWhere((i) => i.invoiceNumber == invoice.invoiceNumber); if (index != -1) { - // 古いファイルが存在し、かつ新しいパスと異なる場合は古いファイルを削除(無駄なPDFの掃除) - final oldPath = all[index].filePath; + final oldInvoice = all[index]; + final oldPath = oldInvoice.filePath; + + // 古いファイルが存在し、かつ新しいパスと異なる場合 if (oldPath != null && oldPath != invoice.filePath) { - await _deletePhysicalFile(oldPath); + // 【重要】共有済みのファイルは、証跡として残すために自動削除から除外する + if (!oldInvoice.isShared) { + await _deletePhysicalFile(oldPath); + } else { + print('Skipping deletion of shared file: $oldPath'); + } } all[index] = invoice; } else { @@ -80,8 +87,11 @@ class InvoiceRepository { } /// DBに登録されていない「浮いたPDFファイル」をスキャンして掃除する + /// ※共有済みフラグが立っているDBエントリーのパスは、削除対象から除外されます。 Future cleanupOrphanedPdfs() async { final List all = await getAllInvoices(); + + // DBに登録されている全ての有効なパス(共有済みも含む)をセットにする final Set registeredPaths = all .where((i) => i.filePath != null) .map((i) => i.filePath!) @@ -95,7 +105,7 @@ class InvoiceRepository { for (var entity in files) { if (entity is File && entity.path.endsWith('.pdf')) { - // DBに登録されていないPDFは削除(無駄なゴミ) + // DBのどの請求データ(最新も共有済みも)にも紐付いていないファイルだけを削除 if (!registeredPaths.contains(entity.path)) { await entity.delete(); deletedCount++; diff --git a/gemi_invoice/lib/services/master_repository.dart b/gemi_invoice/lib/services/master_repository.dart new file mode 100644 index 0000000..d51d86e --- /dev/null +++ b/gemi_invoice/lib/services/master_repository.dart @@ -0,0 +1,150 @@ +import 'dart:convert'; +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import '../models/customer_model.dart'; +import '../models/company_model.dart'; // Companyモデルをインポート +import '../data/product_master.dart'; + +/// 顧客マスター、商品マスター、自社情報のデータをローカルファイルに保存・管理するリポジトリ +class MasterRepository { + static const String _customerFileName = 'customers_master.json'; + static const String _productFileName = 'products_master.json'; + static const String _companyFileName = 'company_info.json'; // 自社情報ファイル名 + + /// 顧客マスターのファイルを取得 + Future _getCustomerFile() async { + final directory = await getApplicationDocumentsDirectory(); + return File('${directory.path}/$_customerFileName'); + } + + /// 商品マスターのファイルを取得 + Future _getProductFile() async { + final directory = await getApplicationDocumentsDirectory(); + return File('${directory.path}/$_productFileName'); + } + + /// 自社情報のファイルを取得 + Future _getCompanyFile() async { + final directory = await getApplicationDocumentsDirectory(); + return File('${directory.path}/$_companyFileName'); + } + + // --- 顧客マスター操作 --- + + /// 全ての顧客データを読み込む + Future> loadCustomers() async { + try { + final file = await _getCustomerFile(); + if (!await file.exists()) return []; + + final String content = await file.readAsString(); + final List jsonList = json.decode(content); + + return jsonList.map((j) => Customer.fromJson(j)).toList(); + } catch (e) { + debugPrint('Customer Master Loading Error: $e'); + return []; + } + } + + /// 顧客リストを保存する + Future saveCustomers(List customers) async { + try { + final file = await _getCustomerFile(); + final String encoded = json.encode(customers.map((c) => c.toJson()).toList()); + await file.writeAsString(encoded); + } catch (e) { + debugPrint('Customer Master Saving Error: $e'); + } + } + + /// 特定の顧客を追加または更新する簡易メソッド + Future upsertCustomer(Customer customer) async { + final customers = await loadCustomers(); + final index = customers.indexWhere((c) => c.id == customer.id); + if (index != -1) { + customers[index] = customer; + } else { + customers.add(customer); + } + await saveCustomers(customers); + } + + // --- 商品マスター操作 --- + + /// 全ての商品データを読み込む + /// ファイルがない場合は、ProductMasterに定義された初期データを返す + Future> loadProducts() async { + try { + final file = await _getProductFile(); + if (!await file.exists()) { + // 初期データが存在しない場合は、ProductMasterのハードコードされたリストを返す + return List.from(ProductMaster.products); + } + + final String content = await file.readAsString(); + final List jsonList = json.decode(content); + + return jsonList.map((j) => Product.fromJson(j)).toList(); + } catch (e) { + debugPrint('Product Master Loading Error: $e'); + return List.from(ProductMaster.products); // エラー時も初期データを返す + } + } + + /// 商品リストを保存する + Future saveProducts(List products) async { + try { + final file = await _getProductFile(); + final String encoded = json.encode(products.map((p) => p.toJson()).toList()); + await file.writeAsString(encoded); + } catch (e) { + debugPrint('Product Master Saving Error: $e'); + } + } + + /// 特定の商品を追加または更新する簡易メソッド + Future upsertProduct(Product product) async { + final products = await loadProducts(); + final index = products.indexWhere((p) => p.id == product.id); + if (index != -1) { + products[index] = product; + } else { + products.add(product); + } + await saveProducts(products); + } + + // --- 自社情報操作 --- + + /// 自社情報を読み込む + /// ファイルがない場合は、Company.defaultCompany を返す + Future loadCompany() async { + try { + final file = await _getCompanyFile(); + if (!await file.exists()) { + return Company.defaultCompany; + } + + final String content = await file.readAsString(); + final Map jsonMap = json.decode(content); + + return Company.fromJson(jsonMap); + } catch (e) { + debugPrint('Company Info Loading Error: $e'); + return Company.defaultCompany; // エラー時もデフォルトを返す + } + } + + /// 自社情報を保存する + Future saveCompany(Company company) async { + try { + final file = await _getCompanyFile(); + final String encoded = json.encode(company.toJson()); + await file.writeAsString(encoded); + } catch (e) { + debugPrint('Company Info Saving Error: $e'); + } + } +} diff --git a/gemi_invoice/lib/services/pdf_generator.dart b/gemi_invoice/lib/services/pdf_generator.dart index 78d059a..150a3c5 100644 --- a/gemi_invoice/lib/services/pdf_generator.dart +++ b/gemi_invoice/lib/services/pdf_generator.dart @@ -1,3 +1,4 @@ +// lib/services/pdf_generator.dart import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart' show debugPrint; @@ -7,9 +8,13 @@ import 'package:pdf/widgets.dart' as pw; import 'package:path_provider/path_provider.dart'; import 'package:crypto/crypto.dart'; import 'package:intl/intl.dart'; +import 'package:printing/printing.dart'; import '../models/invoice_models.dart'; +import '../models/company_model.dart'; // Companyモデルをインポート +import 'master_repository.dart'; // MasterRepositoryをインポート -/// A4サイズのプロフェッショナルな請求書PDFを生成し、保存する +/// A4サイズのプロフェッショナルな帳票PDFを生成し、保存する +/// 見積書、納品書、請求書、領収書の各DocumentTypeに対応 Future generateInvoicePdf(Invoice invoice) async { try { final pdf = pw.Document(); @@ -19,8 +24,16 @@ Future generateInvoicePdf(Invoice invoice) async { final ttf = pw.Font.ttf(fontData); final boldTtf = pw.Font.ttf(fontData); // IPAexGはウェイトが1つなので同じものを使用 + // 自社情報をロード + final MasterRepository masterRepository = MasterRepository(); + final Company company = await masterRepository.loadCompany(); + final dateFormatter = DateFormat('yyyy年MM月dd日'); - final amountFormatter = NumberFormat("#,###"); + final amountFormatter = NumberFormat("¥#,###"); // ¥記号を付ける + + // 帳票の種類に応じたタイトルと接尾辞 + final String docTitle = invoice.type.label; + final String honorific = " 御中"; // 宛名の敬称 (estimateでもinvoiceでも共通化) pdf.addPage( pw.MultiPage( @@ -34,11 +47,11 @@ Future generateInvoicePdf(Invoice invoice) async { child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Text("請求書", style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), + pw.Text(docTitle, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ - pw.Text("請求番号: ${invoice.invoiceNumber}"), + pw.Text("管理番号: ${invoice.invoiceNumber}"), pw.Text("発行日: ${dateFormatter.format(invoice.date)}"), ], ), @@ -55,15 +68,17 @@ Future generateInvoicePdf(Invoice invoice) async { child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Container( - decoration: const pw.BoxDecoration( - border: pw.Border(bottom: pw.BorderSide(width: 1)), + pw.Text("${invoice.customer.formalName}$honorific", + style: const pw.TextStyle(fontSize: 18)), + if (invoice.customer.department != null && invoice.customer.department!.isNotEmpty) + pw.Padding( + padding: const pw.EdgeInsets.only(top: 4), + child: pw.Text(invoice.customer.department!), ), - child: pw.Text(invoice.customer.invoiceName, - style: const pw.TextStyle(fontSize: 18)), - ), pw.SizedBox(height: 10), - pw.Text("下記の通り、ご請求申し上げます。"), + pw.Text(invoice.type == DocumentType.estimate + ? "下記の通り、御見積申し上げます。" + : "下記の通り、ご請求申し上げます。"), ], ), ), @@ -71,10 +86,11 @@ Future generateInvoicePdf(Invoice invoice) async { child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ - pw.Text("自社名が入ります", style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), - pw.Text("〒000-0000"), - pw.Text("住所がここに入ります"), - pw.Text("TEL: 00-0000-0000"), + pw.Text(company.formalName, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), + if (company.zipCode != null && company.zipCode!.isNotEmpty) pw.Text(company.zipCode!), + if (company.address != null && company.address!.isNotEmpty) pw.Text(company.address!), + if (company.tel != null && company.tel!.isNotEmpty) pw.Text(company.tel!), + if (company.registrationNumber != null && company.registrationNumber!.isNotEmpty) pw.Text(company.registrationNumber! ), ], ), ), @@ -89,8 +105,8 @@ Future generateInvoicePdf(Invoice invoice) async { child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ - pw.Text("ご請求金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)), - pw.Text("¥${amountFormatter.format(invoice.totalAmount)} -", + pw.Text("${docTitle}金額合計 (税込)", style: const pw.TextStyle(fontSize: 16)), + pw.Text("${amountFormatter.format(invoice.totalAmount)} -", style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)), ], ), @@ -135,7 +151,7 @@ Future generateInvoicePdf(Invoice invoice) async { _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), _buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)), pw.Divider(), - _buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true), + _buildSummaryRow("合計", amountFormatter.format(invoice.totalAmount), isBold: true), ], ), ), @@ -150,8 +166,7 @@ Future generateInvoicePdf(Invoice invoice) async { width: double.infinity, padding: const pw.EdgeInsets.all(8), decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)), - child: pw.Text(invoice.notes!), - ), + child: pw.Text(invoice.notes!)), ], ], footer: (context) => pw.Container( @@ -165,17 +180,17 @@ Future generateInvoicePdf(Invoice invoice) async { ), ); - // 保存処理 final Uint8List bytes = await pdf.save(); final String hash = sha256.convert(bytes).toString().substring(0, 8); final String dateFileStr = DateFormat('yyyyMMdd').format(invoice.date); - String fileName = "Invoice_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf"; + String fileName = "${invoice.type.name}_${dateFileStr}_${invoice.customer.formalName}_$hash.pdf"; final directory = await getExternalStorageDirectory(); if (directory == null) return null; final file = File("${directory.path}/$fileName"); await file.writeAsBytes(bytes); + return file.path; } catch (e) { debugPrint("PDF Generation Error: $e"); @@ -183,6 +198,88 @@ Future generateInvoicePdf(Invoice invoice) async { } } +/// ポケットサーマルプリンタ向けの58mmレシートPDFを生成して印刷ダイアログを表示する +Future printThermalReceipt(Invoice invoice) async { + try { + final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf"); + final ttf = pw.Font.ttf(fontData); + final amountFormatter = NumberFormat("¥#,###"); // ¥記号を付ける + + // 自社情報をロード + final MasterRepository masterRepository = MasterRepository(); + final Company company = await masterRepository.loadCompany(); + + final doc = pw.Document(); + + doc.addPage( + pw.Page( + // 58mm幅のサーマルプリンタ向け設定 (約164pt) + pageFormat: const PdfPageFormat(58 * PdfPageFormat.mm, double.infinity, marginAll: 2 * PdfPageFormat.mm), + theme: pw.ThemeData.withFont(base: ttf), + build: (pw.Context context) { + return pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Center( + child: pw.Text(invoice.type.label, style: pw.TextStyle(fontSize: 16, fontWeight: pw.FontWeight.bold)), + ), + pw.SizedBox(height: 5), + pw.Text("${invoice.customer.formalName} 様", style: const pw.TextStyle(fontSize: 10)), + pw.Divider(thickness: 1, borderStyle: pw.BorderStyle.dashed), + pw.SizedBox(height: 5), + pw.Center( + child: pw.Text(amountFormatter.format(invoice.totalAmount), + style: pw.TextStyle(fontSize: 18, fontWeight: pw.FontWeight.bold)), + ), + pw.Center(child: pw.Text("(うち消費税 ${amountFormatter.format(invoice.tax)})", style: const pw.TextStyle(fontSize: 8))), + pw.SizedBox(height: 10), + pw.Text("但し、お品代として", style: const pw.TextStyle(fontSize: 9)), + pw.Text("上記正に領収いたしました", style: const pw.TextStyle(fontSize: 9)), + pw.SizedBox(height: 10), + + // 明細簡易表示 + pw.Text("--- 明細 ---\n", style: const pw.TextStyle(fontSize: 8)), + ...invoice.items.map((item) => pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Expanded(child: pw.Text(item.description, style: const pw.TextStyle(fontSize: 8))), + pw.Text("x${item.quantity} ", style: const pw.TextStyle(fontSize: 8)), + pw.Text(amountFormatter.format(item.subtotal), style: const pw.TextStyle(fontSize: 8)), + ], + )), + + pw.Divider(thickness: 0.5), + pw.SizedBox(height: 5), + pw.Align( + alignment: pw.Alignment.centerRight, + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text(company.formalName, style: const pw.TextStyle(fontSize: 9)), + pw.Text(DateFormat('yyyy/MM/dd HH:mm').format(invoice.date), style: const pw.TextStyle(fontSize: 7)), + pw.Text("No: ${invoice.invoiceNumber}", style: const pw.TextStyle(fontSize: 7)), + ], + ), + ), + pw.SizedBox(height: 10), + pw.Center(child: pw.Text("ありがとうございました", style: const pw.TextStyle(fontSize: 8))), + pw.SizedBox(height: 20), // 切り取り用の余白 + ], + ); + }, + ), + ); + + // 印刷ダイアログを表示 + await Printing.layoutPdf( + onLayout: (PdfPageFormat format) async => doc.save(), + name: "${invoice.type.name}_${invoice.invoiceNumber}", + ); + } catch (e) { + debugPrint("Thermal Print Error: $e"); + } +} + pw.Widget _buildSummaryRow(String label, String value, {bool isBold = false}) { final style = pw.TextStyle(fontSize: 12, fontWeight: isBold ? pw.FontWeight.bold : null); return pw.Padding( diff --git a/gemi_invoice/linux/flutter/generated_plugin_registrant.cc b/gemi_invoice/linux/flutter/generated_plugin_registrant.cc index f6f23bf..2dccc22 100644 --- a/gemi_invoice/linux/flutter/generated_plugin_registrant.cc +++ b/gemi_invoice/linux/flutter/generated_plugin_registrant.cc @@ -6,9 +6,13 @@ #include "generated_plugin_registrant.h" +#include #include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) printing_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "PrintingPlugin"); + printing_plugin_register_with_registrar(printing_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/gemi_invoice/linux/flutter/generated_plugins.cmake b/gemi_invoice/linux/flutter/generated_plugins.cmake index f16b4c3..45f2369 100644 --- a/gemi_invoice/linux/flutter/generated_plugins.cmake +++ b/gemi_invoice/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + printing url_launcher_linux ) diff --git a/gemi_invoice/macos/Flutter/GeneratedPluginRegistrant.swift b/gemi_invoice/macos/Flutter/GeneratedPluginRegistrant.swift index fccdd16..0413654 100644 --- a/gemi_invoice/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/gemi_invoice/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,10 +5,12 @@ import FlutterMacOS import Foundation +import printing import share_plus import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + PrintingPlugin.register(with: registry.registrar(forPlugin: "PrintingPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/gemi_invoice/pubspec.lock b/gemi_invoice/pubspec.lock index 556910c..601cc95 100644 --- a/gemi_invoice/pubspec.lock +++ b/gemi_invoice/pubspec.lock @@ -176,6 +176,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + http: + dependency: transitive + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.dev" + source: hosted + version: "1.6.0" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" + url: "https://pub.dev" + source: hosted + version: "4.1.2" image: dependency: transitive description: @@ -360,6 +376,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.11.3" + pdf_widget_wrapper: + dependency: transitive + description: + name: pdf_widget_wrapper + sha256: c930860d987213a3d58c7ec3b7ecf8085c3897f773e8dc23da9cae60a5d6d0f5 + url: "https://pub.dev" + source: hosted + version: "1.0.4" permission_handler: dependency: "direct main" description: @@ -440,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.3" + printing: + dependency: "direct main" + description: + name: printing + sha256: "482cd5a5196008f984bb43ed0e47cbfdca7373490b62f3b27b3299275bf22a93" + url: "https://pub.dev" + source: hosted + version: "5.14.2" pub_semver: dependency: transitive description: @@ -598,7 +630,7 @@ packages: source: hosted version: "3.1.5" uuid: - dependency: transitive + dependency: "direct main" description: name: uuid sha256: a11b666489b1954e01d992f3d601b1804a33937b5a8fe677bd26b8a9f96f96e8 diff --git a/gemi_invoice/pubspec.yaml b/gemi_invoice/pubspec.yaml index 3a71a0f..dcb3fd1 100644 --- a/gemi_invoice/pubspec.yaml +++ b/gemi_invoice/pubspec.yaml @@ -43,6 +43,8 @@ dependencies: share_plus: ^12.0.1 url_launcher: ^6.3.2 open_filex: ^4.7.0 + printing: ^5.13.2 + uuid: ^4.5.1 dev_dependencies: flutter_test: diff --git a/gemi_invoice/windows/flutter/generated_plugin_registrant.cc b/gemi_invoice/windows/flutter/generated_plugin_registrant.cc index d5013ba..cb63ddc 100644 --- a/gemi_invoice/windows/flutter/generated_plugin_registrant.cc +++ b/gemi_invoice/windows/flutter/generated_plugin_registrant.cc @@ -7,12 +7,15 @@ #include "generated_plugin_registrant.h" #include +#include #include #include void RegisterPlugins(flutter::PluginRegistry* registry) { PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + PrintingPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PrintingPlugin")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UrlLauncherWindowsRegisterWithRegistrar( diff --git a/gemi_invoice/windows/flutter/generated_plugins.cmake b/gemi_invoice/windows/flutter/generated_plugins.cmake index a0d1388..0f8c9e2 100644 --- a/gemi_invoice/windows/flutter/generated_plugins.cmake +++ b/gemi_invoice/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST permission_handler_windows + printing share_plus url_launcher_windows )