From 421bcc01f67c131870b27afdea75572718666adb Mon Sep 17 00:00:00 2001 From: joe Date: Thu, 26 Feb 2026 06:20:30 +0900 Subject: [PATCH] Work in progress before switching to main --- gitremotepush.sh | 3 + ios/Flutter/Generated.xcconfig | 4 +- ios/Flutter/flutter_export_environment.sh | 4 +- lib/main.dart | 9 + lib/models/customer_contact.dart | 47 +++ lib/models/customer_model.dart | 19 +- lib/models/invoice_models.dart | 20 + lib/screens/customer_master_screen.dart | 228 ++++++++++- lib/screens/invoice_detail_page.dart | 234 +++++++---- lib/screens/invoice_history_screen.dart | 212 ++++++---- lib/screens/invoice_input_screen.dart | 64 +-- lib/services/customer_repository.dart | 93 ++++- lib/services/database_helper.dart | 60 ++- lib/services/invoice_repository.dart | 20 +- lib/services/pdf_generator.dart | 377 +++++++++--------- lib/widgets/invoice_pdf_preview_page.dart | 117 ++++++ .../ephemeral/Flutter-Generated.xcconfig | 4 +- .../ephemeral/flutter_export_environment.sh | 4 +- 18 files changed, 1106 insertions(+), 413 deletions(-) create mode 100644 gitremotepush.sh create mode 100644 lib/models/customer_contact.dart create mode 100644 lib/widgets/invoice_pdf_preview_page.dart diff --git a/gitremotepush.sh b/gitremotepush.sh new file mode 100644 index 0000000..541d136 --- /dev/null +++ b/gitremotepush.sh @@ -0,0 +1,3 @@ +git remote add origin git@git.cyberius.biz:joe/h-1.flutter.0.git +git push -u origin main + diff --git a/ios/Flutter/Generated.xcconfig b/ios/Flutter/Generated.xcconfig index c4092cc..6740e2a 100644 --- a/ios/Flutter/Generated.xcconfig +++ b/ios/Flutter/Generated.xcconfig @@ -4,8 +4,8 @@ FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0 COCOAPODS_PARALLEL_CODE_SIGN=true FLUTTER_TARGET=lib/main.dart FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=1.5.0 -FLUTTER_BUILD_NUMBER=150 +FLUTTER_BUILD_NAME=1.5.06 +FLUTTER_BUILD_NUMBER=151 EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386 EXCLUDED_ARCHS[sdk=iphoneos*]=armv7 DART_OBFUSCATION=false diff --git a/ios/Flutter/flutter_export_environment.sh b/ios/Flutter/flutter_export_environment.sh index 3300aa3..1aa1b8f 100755 --- a/ios/Flutter/flutter_export_environment.sh +++ b/ios/Flutter/flutter_export_environment.sh @@ -5,8 +5,8 @@ export "FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0" export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "FLUTTER_TARGET=lib/main.dart" export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=1.5.0" -export "FLUTTER_BUILD_NUMBER=150" +export "FLUTTER_BUILD_NAME=1.5.06" +export "FLUTTER_BUILD_NUMBER=151" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=true" export "TREE_SHAKE_ICONS=false" diff --git a/lib/main.dart b/lib/main.dart index 01e7b17..4767171 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -60,6 +60,15 @@ class MyApp extends StatelessWidget { useMaterial3: true, fontFamily: 'IPAexGothic', ), + builder: (context, child) { + return InteractiveViewer( + panEnabled: false, + scaleEnabled: true, + minScale: 0.8, + maxScale: 2.0, + child: child ?? const SizedBox.shrink(), + ); + }, home: const InvoiceHistoryScreen(), ); } diff --git a/lib/models/customer_contact.dart b/lib/models/customer_contact.dart new file mode 100644 index 0000000..485607f --- /dev/null +++ b/lib/models/customer_contact.dart @@ -0,0 +1,47 @@ +class CustomerContact { + final String id; + final String customerId; + final String? email; + final String? tel; + final String? address; + final int version; + final bool isActive; + final DateTime createdAt; + + CustomerContact({ + required this.id, + required this.customerId, + this.email, + this.tel, + this.address, + required this.version, + this.isActive = true, + DateTime? createdAt, + }) : createdAt = createdAt ?? DateTime.now(); + + factory CustomerContact.fromMap(Map map) { + return CustomerContact( + id: map['id'], + customerId: map['customer_id'], + email: map['email'], + tel: map['tel'], + address: map['address'], + version: map['version'], + isActive: (map['is_active'] ?? 0) == 1, + createdAt: DateTime.parse(map['created_at']), + ); + } + + Map toMap() { + return { + 'id': id, + 'customer_id': customerId, + 'email': email, + 'tel': tel, + 'address': address, + 'version': version, + 'is_active': isActive ? 1 : 0, + 'created_at': createdAt.toIso8601String(), + }; + } +} diff --git a/lib/models/customer_model.dart b/lib/models/customer_model.dart index a992d86..2d31c9b 100644 --- a/lib/models/customer_model.dart +++ b/lib/models/customer_model.dart @@ -5,8 +5,10 @@ class Customer { final String formalName; // 請求書用正式名称 final String title; // 敬称(様、殿など) final String? department; // 部署名 - final String? address; // 住所 - final String? tel; // 電話番号 + final String? address; // 住所(最新連絡先) + final String? tel; // 電話番号(最新連絡先) + final String? email; // メール(最新連絡先) + final int? contactVersionId; // 連絡先バージョン final String? odooId; // Odoo側のID final bool isSynced; // 同期フラグ final DateTime updatedAt; // 最終更新日時 @@ -20,6 +22,8 @@ class Customer { this.department, this.address, this.tel, + this.email, + this.contactVersionId, this.odooId, this.isSynced = false, DateTime? updatedAt, @@ -43,6 +47,7 @@ class Customer { 'department': department, 'address': address, 'tel': tel, + 'contact_version_id': contactVersionId, 'odoo_id': odooId, 'is_locked': isLocked ? 1 : 0, 'is_synced': isSynced ? 1 : 0, @@ -57,8 +62,10 @@ class Customer { formalName: map['formal_name'], title: map['title'] ?? "様", department: map['department'], - address: map['address'], - tel: map['tel'], + address: map['contact_address'] ?? map['address'], + tel: map['contact_tel'] ?? map['tel'], + email: map['contact_email'], + contactVersionId: map['contact_version_id'], odooId: map['odoo_id'], isLocked: (map['is_locked'] ?? 0) == 1, isSynced: map['is_synced'] == 1, @@ -78,6 +85,8 @@ class Customer { bool? isSynced, DateTime? updatedAt, bool? isLocked, + String? email, + int? contactVersionId, }) { return Customer( id: id ?? this.id, @@ -87,6 +96,8 @@ class Customer { department: department ?? this.department, address: address ?? this.address, tel: tel ?? this.tel, + email: email ?? this.email, + contactVersionId: contactVersionId ?? this.contactVersionId, odooId: odooId ?? this.odooId, isSynced: isSynced ?? this.isSynced, updatedAt: updatedAt ?? this.updatedAt, diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index 3b9e148..6db28c9 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -84,6 +84,10 @@ class Invoice { final bool isDraft; // 追加: 下書きフラグ final String? subject; // 追加: 案件名 final bool isLocked; // 追加: ロック + final int? contactVersionId; // 追加: 連絡先バージョン + final String? contactEmailSnapshot; + final String? contactTelSnapshot; + final String? contactAddressSnapshot; Invoice({ String? id, @@ -104,6 +108,10 @@ class Invoice { this.isDraft = false, // 追加: デフォルトは通常 this.subject, // 追加: 案件 this.isLocked = false, + this.contactVersionId, + this.contactEmailSnapshot, + this.contactTelSnapshot, + this.contactAddressSnapshot, }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), terminalId = terminalId ?? "T1", // デフォルト端末ID updatedAt = updatedAt ?? DateTime.now(); @@ -163,6 +171,10 @@ class Invoice { 'is_draft': isDraft ? 1 : 0, // 追加 'subject': subject, // 追加 'is_locked': isLocked ? 1 : 0, + 'contact_version_id': contactVersionId, + 'contact_email_snapshot': contactEmailSnapshot, + 'contact_tel_snapshot': contactTelSnapshot, + 'contact_address_snapshot': contactAddressSnapshot, }; } @@ -185,6 +197,10 @@ class Invoice { bool? isDraft, String? subject, bool? isLocked, + int? contactVersionId, + String? contactEmailSnapshot, + String? contactTelSnapshot, + String? contactAddressSnapshot, }) { return Invoice( id: id ?? this.id, @@ -205,6 +221,10 @@ class Invoice { isDraft: isDraft ?? this.isDraft, subject: subject ?? this.subject, isLocked: isLocked ?? this.isLocked, + contactVersionId: contactVersionId ?? this.contactVersionId, + contactEmailSnapshot: contactEmailSnapshot ?? this.contactEmailSnapshot, + contactTelSnapshot: contactTelSnapshot ?? this.contactTelSnapshot, + contactAddressSnapshot: contactAddressSnapshot ?? this.contactAddressSnapshot, ); } diff --git a/lib/screens/customer_master_screen.dart b/lib/screens/customer_master_screen.dart index b10f1b6..25185ee 100644 --- a/lib/screens/customer_master_screen.dart +++ b/lib/screens/customer_master_screen.dart @@ -24,6 +24,47 @@ class _CustomerMasterScreenState extends State { _loadCustomers(); } + Future _showContactUpdateDialog(Customer customer) async { + final emailController = TextEditingController(text: customer.email ?? ""); + final telController = TextEditingController(text: customer.tel ?? ""); + final addressController = TextEditingController(text: customer.address ?? ""); + + final updated = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('連絡先を更新'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField(controller: emailController, decoration: const InputDecoration(labelText: 'メール')), + TextField(controller: telController, decoration: const InputDecoration(labelText: '電話番号'), keyboardType: TextInputType.phone), + TextField(controller: addressController, decoration: const InputDecoration(labelText: '住所')), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text('キャンセル')), + ElevatedButton( + onPressed: () async { + await _customerRepo.updateContact( + customerId: customer.id, + email: emailController.text.isEmpty ? null : emailController.text, + tel: telController.text.isEmpty ? null : telController.text, + address: addressController.text.isEmpty ? null : addressController.text, + ); + if (!mounted) return; + Navigator.pop(context, true); + }, + child: const Text('保存'), + ), + ], + ), + ); + + if (updated == true) { + _loadCustomers(); + } + } + Future _loadCustomers() async { setState(() => _isLoading = true); final customers = await _customerRepo.getAllCustomers(); @@ -130,6 +171,165 @@ class _CustomerMasterScreenState extends State { } } + Future _showPhonebookImport() async { + // 疑似電話帳データ(会社名/氏名/複数住所) + final phonebook = [ + { + 'company': '佐々木製作所', + 'person': '佐々木 太郎', + 'addresses': ['大阪府大阪市北区1-1-1', '東京都千代田区丸の内2-2-2'], + 'tel': '06-1234-5678', + 'emails': ['info@sasaki.co.jp', 'taro@sasaki.co.jp'], + }, + { + 'company': 'Gemini Solutions', + 'person': 'John Smith', + 'addresses': ['1 Infinite Loop, CA', '1600 Amphitheatre Pkwy, CA'], + 'tel': '03-9876-5432', + 'emails': ['contact@gemini.com', 'john.smith@gemini.com'], + }, + ]; + + String selectedEntryId = '0'; + String selectedNameSource = 'company'; + int selectedAddressIndex = 0; + int selectedEmailIndex = 0; + + final imported = await showDialog( + context: context, + builder: (context) => StatefulBuilder( + builder: (context, setDialogState) { + final entry = phonebook[int.parse(selectedEntryId)]; + final addresses = (entry['addresses'] as List); + final emails = (entry['emails'] as List); + final displayName = selectedNameSource == 'company' ? entry['company'] as String : entry['person'] as String; + final formalName = selectedNameSource == 'company' + ? '株式会社 ${entry['company']}' + : '${entry['person']} 様'; + final addressText = addresses[selectedAddressIndex]; + final emailText = emails.isNotEmpty ? emails[selectedEmailIndex] : ''; + + final displayController = TextEditingController(text: displayName); + final formalController = TextEditingController(text: formalName); + final addressController = TextEditingController(text: addressText); + final emailController = TextEditingController(text: emailText); + + return AlertDialog( + title: const Text('電話帳から取り込む'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + value: selectedEntryId, + decoration: const InputDecoration(labelText: '電話帳エントリ'), + items: phonebook + .asMap() + .entries + .map((e) => DropdownMenuItem(value: e.key.toString(), child: Text(e.value['company'] as String))) + .toList(), + onChanged: (v) { + setDialogState(() { + selectedEntryId = v ?? '0'; + selectedAddressIndex = 0; + }); + }, + ), + const SizedBox(height: 8), + const Text('顧客名の取り込み元'), + Row( + children: [ + Expanded( + child: RadioListTile( + dense: true, + title: const Text('会社名'), + value: 'company', + groupValue: selectedNameSource, + onChanged: (v) => setDialogState(() => selectedNameSource = v ?? 'company'), + ), + ), + Expanded( + child: RadioListTile( + dense: true, + title: const Text('氏名'), + value: 'person', + groupValue: selectedNameSource, + onChanged: (v) => setDialogState(() => selectedNameSource = v ?? 'person'), + ), + ), + ], + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: selectedAddressIndex, + decoration: const InputDecoration(labelText: '住所を選択'), + items: addresses + .asMap() + .entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => setDialogState(() => selectedAddressIndex = v ?? 0), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: selectedEmailIndex, + decoration: const InputDecoration(labelText: 'メールを選択'), + items: emails + .asMap() + .entries + .map((e) => DropdownMenuItem(value: e.key, child: Text(e.value))) + .toList(), + onChanged: (v) => setDialogState(() => selectedEmailIndex = v ?? 0), + ), + const SizedBox(height: 12), + TextField( + controller: displayController, + decoration: const InputDecoration(labelText: '表示名(編集可)'), + ), + TextField( + controller: formalController, + decoration: const InputDecoration(labelText: '正式名称(編集可)'), + ), + TextField( + controller: addressController, + decoration: const InputDecoration(labelText: '住所(編集可)'), + ), + TextField( + controller: emailController, + decoration: const InputDecoration(labelText: 'メール(編集可)'), + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')), + ElevatedButton( + onPressed: () { + final newCustomer = Customer( + id: const Uuid().v4(), + displayName: displayController.text, + formalName: formalController.text, + title: selectedNameSource == 'company' ? '御中' : '様', + address: addressController.text, + tel: entry['tel'] as String?, + email: emailController.text.isEmpty ? null : emailController.text, + isSynced: false, + ); + Navigator.pop(context, newCustomer); + }, + child: const Text('取り込む'), + ), + ], + ); + }, + ), + ); + + if (imported != null) { + await _customerRepo.saveCustomer(imported); + _loadCustomers(); + } + } + @override Widget build(BuildContext context) { return Scaffold( @@ -210,10 +410,12 @@ class _CustomerMasterScreenState extends State { ), ], ), - floatingActionButton: FloatingActionButton( - onPressed: () => _addOrEditCustomer(), - child: const Icon(Icons.person_add), + floatingActionButton: FloatingActionButton.extended( + onPressed: _showPhonebookImport, + icon: const Icon(Icons.add), + label: const Text('電話帳から取り込む'), backgroundColor: Colors.indigo, + foregroundColor: Colors.white, ), ); } @@ -250,19 +452,31 @@ class _CustomerMasterScreenState extends State { const SizedBox(height: 8), if (c.address != null) Text("住所: ${c.address}") else const SizedBox.shrink(), if (c.tel != null) Text("TEL: ${c.tel}") else const SizedBox.shrink(), + if (c.email != null) Text("メール: ${c.email}") else const SizedBox.shrink(), Text("敬称: ${c.title}"), const SizedBox(height: 12), Row( children: [ OutlinedButton.icon( - onPressed: () { - Navigator.pop(context); - _addOrEditCustomer(customer: c); - }, + onPressed: c.isLocked + ? null + : () { + Navigator.pop(context); + _addOrEditCustomer(customer: c); + }, icon: const Icon(Icons.edit), label: const Text("編集"), ), const SizedBox(width: 8), + OutlinedButton.icon( + onPressed: () { + Navigator.pop(context); + _showContactUpdateDialog(c); + }, + icon: const Icon(Icons.contact_mail), + label: const Text("連絡先を更新"), + ), + const SizedBox(width: 8), if (!c.isLocked) OutlinedButton.icon( onPressed: () async { diff --git a/lib/screens/invoice_detail_page.dart b/lib/screens/invoice_detail_page.dart index 5cb80e0..83c76aa 100644 --- a/lib/screens/invoice_detail_page.dart +++ b/lib/screens/invoice_detail_page.dart @@ -3,6 +3,9 @@ import 'invoice_input_screen.dart'; // Add this line import 'package:intl/intl.dart'; import 'package:share_plus/share_plus.dart'; import 'package:open_filex/open_filex.dart'; +import 'package:printing/printing.dart'; +import 'dart:typed_data'; +import '../widgets/invoice_pdf_preview_page.dart'; import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; import '../services/invoice_repository.dart'; @@ -34,6 +37,7 @@ class _InvoiceDetailPageState extends State { final _customerRepo = CustomerRepository(); final _companyRepo = CompanyRepository(); CompanyInfo? _companyInfo; + bool _showFormalWarning = true; @override void initState() { @@ -147,8 +151,8 @@ class _InvoiceDetailPageState extends State { Widget build(BuildContext context) { final fmt = NumberFormat("#,###"); final isDraft = _currentInvoice.isDraft; - final themeColor = isDraft ? Colors.blueGrey.shade800 : Colors.white; - final textColor = isDraft ? Colors.white : Colors.black87; + final themeColor = Colors.white; // 常に明色 + final textColor = Colors.black87; final locked = _currentInvoice.isLocked; @@ -156,24 +160,37 @@ class _InvoiceDetailPageState extends State { backgroundColor: themeColor, appBar: AppBar( leading: const BackButton(), // 常に表示 - title: Text(isDraft ? "伝票詳細 (下書き)" : "販売アシスト1号 伝票詳細"), - backgroundColor: isDraft ? Colors.black87 : Colors.blueGrey, + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + isDraft ? "伝票詳細" : "販売アシスト1号 伝票詳細", + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + if (isDraft) + Chip( + label: const Text("下書き", style: TextStyle(color: Colors.white)), + backgroundColor: Colors.orange, + padding: EdgeInsets.zero, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + ], + ), + backgroundColor: Colors.indigo.shade700, actions: [ if (locked) Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), child: Chip( - label: const Text("ロック中", style: TextStyle(color: Colors.white)), + label: const Text("確定済み", style: TextStyle(color: Colors.white)), avatar: const Icon(Icons.lock, size: 16, color: Colors.white), backgroundColor: Colors.redAccent, ), ), - if (isDraft && !_isEditing) - TextButton.icon( - icon: const Icon(Icons.check_circle_outline, color: Colors.orangeAccent), - label: const Text("正式発行", style: TextStyle(color: Colors.orangeAccent)), - onPressed: _showPromoteDialog, - ), if (!_isEditing) ...[ IconButton(icon: const Icon(Icons.grid_on), onPressed: _exportCsv, tooltip: "CSV出力"), if (widget.isUnlocked && !locked) @@ -198,34 +215,31 @@ class _InvoiceDetailPageState extends State { ); }, ), - if (widget.isUnlocked && !locked) - IconButton( - icon: const Icon(Icons.edit_note), - tooltip: "詳細編集", - onPressed: () async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => InvoiceInputForm( - onInvoiceGenerated: (inv, path) {}, - existingInvoice: _currentInvoice, - ), - ), - ); - final repo = InvoiceRepository(); - final customerRepo = CustomerRepository(); - final customers = await customerRepo.getAllCustomers(); - final updated = (await repo.getAllInvoices(customers)).firstWhere((i) => i.id == _currentInvoice.id, orElse: () => _currentInvoice); - setState(() => _currentInvoice = updated); - }, - ), + IconButton( + icon: const Icon(Icons.edit_note, color: Colors.white), + tooltip: locked + ? "ロック中" + : (widget.isUnlocked ? "詳細編集" : "アンロックして編集"), + onPressed: (locked || !widget.isUnlocked) + ? null + : () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoiceInputForm( + onInvoiceGenerated: (inv, path) {}, + existingInvoice: _currentInvoice, + ), + ), + ); + final repo = InvoiceRepository(); + final customerRepo = CustomerRepository(); + final customers = await customerRepo.getAllCustomers(); + final updated = (await repo.getAllInvoices(customers)).firstWhere((i) => i.id == _currentInvoice.id, orElse: () => _currentInvoice); + setState(() => _currentInvoice = updated); + }, + ), ] else ...[ - if (isDraft) - TextButton.icon( - icon: const Icon(Icons.check_circle_outline, color: Colors.orangeAccent), - label: const Text("正式発行", style: TextStyle(color: Colors.orangeAccent)), - onPressed: _showPromoteDialog, - ), IconButton(icon: const Icon(Icons.save), onPressed: _saveChanges), IconButton(icon: const Icon(Icons.cancel), onPressed: () => setState(() => _isEditing = false)), ] @@ -236,6 +250,29 @@ class _InvoiceDetailPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + if (isDraft) + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 8), + decoration: BoxDecoration( + color: Colors.orange.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.orange.shade200), + ), + child: Row( + children: const [ + Icon(Icons.edit_note, color: Colors.orange), + SizedBox(width: 8), + Expanded( + child: Text( + "下書き: 未確定・PDFは正式発行で確定", + style: TextStyle(color: Colors.orange), + ), + ), + ], + ), + ), _buildHeaderSection(textColor), if (_isEditing) ...[ const SizedBox(height: 16), @@ -243,7 +280,7 @@ class _InvoiceDetailPageState extends State { const SizedBox(height: 16), _buildExperimentalSection(isDraft), ], - Divider(height: 32, color: isDraft ? Colors.white70 : Colors.grey), + Divider(height: 32, color: Colors.grey.shade400), Text("明細一覧", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: textColor)), const SizedBox(height: 8), _buildItemTable(fmt, textColor, isDraft), @@ -333,22 +370,12 @@ class _InvoiceDetailPageState extends State { ], if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)), - if (_currentInvoice.latitude != null) ...[ - const SizedBox(height: 4), - Padding( - padding: const EdgeInsets.only(top: 4.0), - child: Row( - children: [ - const Icon(Icons.location_on, size: 14, color: Colors.blueGrey), - const SizedBox(width: 4), - Text( - "座標: ${_currentInvoice.latitude!.toStringAsFixed(4)}, ${_currentInvoice.longitude!.toStringAsFixed(4)}", - style: const TextStyle(fontSize: 12, color: Colors.blueGrey), - ), - ], - ), - ), - ], + if ((_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address) != null) + Text("住所: ${_currentInvoice.contactAddressSnapshot ?? _currentInvoice.customer.address}", style: TextStyle(color: textColor)), + if ((_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel) != null) + Text("TEL: ${_currentInvoice.contactTelSnapshot ?? _currentInvoice.customer.tel}", style: TextStyle(color: textColor)), + if ((_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email) != null) + Text("メール: ${_currentInvoice.contactEmailSnapshot ?? _currentInvoice.customer.email}", style: TextStyle(color: textColor)), if (_currentInvoice.notes?.isNotEmpty ?? false) ...[ const SizedBox(height: 8), Text("備考: ${_currentInvoice.notes}", style: TextStyle(color: textColor.withOpacity(0.9))), @@ -498,12 +525,20 @@ class _InvoiceDetailPageState extends State { } Widget _buildFooterActions() { - if (_isEditing || _currentFilePath == null) return const SizedBox(); + if (_isEditing) return const SizedBox(); return Row( children: [ Expanded( child: ElevatedButton.icon( - onPressed: _openPdf, + onPressed: _previewPdf, + icon: const Icon(Icons.picture_as_pdf), + label: const Text("PDFプレビュー"), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton.icon( + onPressed: _currentFilePath != null ? _openPdf : null, icon: const Icon(Icons.launch), label: const Text("PDFを開く"), style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white), @@ -512,7 +547,7 @@ class _InvoiceDetailPageState extends State { const SizedBox(width: 12), Expanded( child: ElevatedButton.icon( - onPressed: _sharePdf, + onPressed: _currentFilePath != null ? _sharePdf : null, icon: const Icon(Icons.share), label: const Text("共有"), style: ElevatedButton.styleFrom(backgroundColor: Colors.green, foregroundColor: Colors.white), @@ -523,19 +558,54 @@ class _InvoiceDetailPageState extends State { } Future _showPromoteDialog() async { + bool showWarning = _showFormalWarning; final confirm = await showDialog( context: context, - builder: (context) => AlertDialog( - title: const Text("正式発行"), - content: const Text("この下書き伝票を「確定」として正式に発行しますか?\n(下書きモードが解除され、通常の背景に戻ります)"), - actions: [ - TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")), - ElevatedButton( - onPressed: () => Navigator.pop(context, true), - style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), - child: const Text("正式発行する"), - ), - ], + builder: (context) => StatefulBuilder( + builder: (context, setStateDialog) { + return AlertDialog( + title: const Text("正式発行"), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text("この下書き伝票を「確定」として正式に発行しますか?"), + const SizedBox(height: 8), + if (showWarning) + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + border: Border.all(color: Colors.redAccent, width: 1), + ), + child: const Text( + "確定すると暗号チェーンシステムに組み込まれ、二度と編集できません。内容を最終確認のうえ実行してください。", + style: TextStyle(color: Colors.redAccent, fontWeight: FontWeight.bold), + ), + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text("警告文を表示"), + value: showWarning, + onChanged: (val) { + setStateDialog(() => showWarning = val); + setState(() => _showFormalWarning = val); + }, + ), + ], + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")), + ElevatedButton( + onPressed: () => Navigator.pop(context, true), + style: ElevatedButton.styleFrom(backgroundColor: Colors.orange), + child: const Text("正式発行する"), + ), + ], + ); + }, ), ); @@ -583,6 +653,32 @@ class _InvoiceDetailPageState extends State { await Share.shareXFiles([XFile(_currentFilePath!)], text: '請求書送付'); } } + + Future _buildPdfBytes() async { + final doc = await buildInvoiceDocument(_currentInvoice); + return Uint8List.fromList(await doc.save()); + } + + Future _previewPdf() async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoicePdfPreviewPage( + invoice: _currentInvoice, + isUnlocked: widget.isUnlocked, + isLocked: _currentInvoice.isLocked, + allowFormalIssue: true, + onFormalIssue: () async { + await _showPromoteDialog(); + return !_currentInvoice.isDraft; + }, + showShare: true, + showEmail: true, + showPrint: true, + ), + ), + ); + } } class _TableCell extends StatelessWidget { diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index 55c2df0..23868cb 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -4,15 +4,19 @@ import '../models/invoice_models.dart'; import '../models/customer_model.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; +import '../services/pdf_generator.dart'; import 'invoice_detail_page.dart'; import 'management_screen.dart'; import 'product_master_screen.dart'; import 'customer_master_screen.dart'; +import 'invoice_input_screen.dart'; import 'settings_screen.dart'; import 'company_info_screen.dart'; import '../widgets/slide_to_unlock.dart'; import '../main.dart'; // InvoiceFlowScreen 用 import 'package:package_info_plus/package_info_plus.dart'; +import 'package:printing/printing.dart'; +import '../widgets/invoice_pdf_preview_page.dart'; class InvoiceHistoryScreen extends StatefulWidget { const InvoiceHistoryScreen({Key? key}) : super(key: key); @@ -41,6 +45,96 @@ class _InvoiceHistoryScreenState extends State { _loadVersion(); } + Future _showInvoiceActions(Invoice invoice) async { + if (invoice.isLocked) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ロック中の伝票は操作できません"))); + return; + } + if (!_isUnlocked) { + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("操作するにはアンロックが必要です"))); + return; + } + await showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (context) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.picture_as_pdf), + title: const Text("PDFプレビュー"), + onTap: () async { + Navigator.pop(context); + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoicePdfPreviewPage( + invoice: invoice, + isUnlocked: _isUnlocked, + isLocked: invoice.isLocked, + allowFormalIssue: !invoice.isLocked, + onFormalIssue: () async { + final repo = InvoiceRepository(); + final promoted = invoice.copyWith(isDraft: false); + await repo.updateInvoice(promoted); + _loadData(); + return true; + }, + showShare: true, + showEmail: true, + showPrint: true, + ), + ), + ); + _loadData(); + }, + ), + ListTile( + leading: const Icon(Icons.edit), + title: const Text("編集"), + onTap: () async { + Navigator.pop(context); + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoiceInputForm( + existingInvoice: invoice, + onInvoiceGenerated: (inv, path) {}, + ), + ), + ); + _loadData(); + }, + ), + ListTile( + leading: const Icon(Icons.delete, color: Colors.redAccent), + title: const Text("削除", style: TextStyle(color: Colors.redAccent)), + onTap: () async { + Navigator.pop(context); + final confirm = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text("伝票の削除"), + content: Text("「${invoice.customerNameForDisplay}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"), + 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 (confirm == true) { + await _invoiceRepo.deleteInvoice(invoice.id); + _loadData(); + } + }, + ), + ], + ), + ), + ); + } + Future _loadVersion() async { final packageInfo = await PackageInfo.fromPlatform(); setState(() { @@ -223,45 +317,6 @@ class _InvoiceHistoryScreenState extends State { ), ), ), - drawer: Drawer( - child: ListView( - padding: EdgeInsets.zero, - children: [ - const DrawerHeader( - decoration: BoxDecoration(color: Colors.blueGrey), - child: Text("販売アシスト1号", style: TextStyle(color: Colors.white, fontSize: 24)), - ), - ListTile( - leading: const Icon(Icons.history), - title: const Text("伝票マスター一覧"), - onTap: () => Navigator.pop(context), - ), - ListTile( - leading: const Icon(Icons.add_task), - title: const Text("新規伝票作成"), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (context) => InvoiceFlowScreen(onComplete: _loadData)), - ); - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.admin_panel_settings), - title: const Text("マスター管理・同期"), - onTap: () { - Navigator.pop(context); - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const ManagementScreen()), - ); - }, - ), - ], - ), - ), body: Column( children: [ Padding( @@ -327,17 +382,43 @@ class _InvoiceHistoryScreenState extends State { ], ), subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"), - trailing: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.end, - children: [ - Text("¥${amountFormatter.format(invoice.totalAmount)}", - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - if (invoice.isSynced) - const Icon(Icons.sync, size: 16, color: Colors.green) - else - const Icon(Icons.sync_disabled, size: 16, color: Colors.orange), - ], + trailing: SizedBox( + height: 56, + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text("¥${amountFormatter.format(invoice.totalAmount)}", + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), + const SizedBox(height: 2), + if (invoice.isSynced) + const Icon(Icons.sync, size: 14, color: Colors.green) + else + const Icon(Icons.sync_disabled, size: 14, color: Colors.orange), + const SizedBox(height: 4), + IconButton( + padding: EdgeInsets.zero, + constraints: const BoxConstraints.tightFor(width: 32, height: 28), + icon: const Icon(Icons.edit, size: 18), + tooltip: invoice.isLocked ? "ロック中" : (_isUnlocked ? "編集" : "アンロックして編集"), + onPressed: (invoice.isLocked || !_isUnlocked) + ? null + : () async { + await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoiceInputForm( + existingInvoice: invoice, + onInvoiceGenerated: (inv, path) {}, + ), + ), + ); + _loadData(); + }, + ), + ], + ), ), onTap: () async { await Navigator.push( @@ -351,36 +432,7 @@ class _InvoiceHistoryScreenState extends State { ); _loadData(); }, - onLongPress: () async { - if (invoice.isLocked) { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("ロック中の伝票は削除できません"))); - return; - } - if (!_isUnlocked) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text("削除するにはアンロックが必要です")), - ); - return; - } - final confirm = await showDialog( - context: context, - builder: (context) => AlertDialog( - title: const Text("伝票の削除"), - content: Text("「${invoice.customerNameForDisplay}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"), - 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 (confirm == true) { - await _invoiceRepo.deleteInvoice(invoice.id); - _loadData(); - } - }, + onLongPress: () => _showInvoiceActions(invoice), ); }, ), diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index 492fbb9..1d96357 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -6,7 +6,8 @@ import '../models/invoice_models.dart'; import '../services/pdf_generator.dart'; import '../services/invoice_repository.dart'; import '../services/customer_repository.dart'; -import 'package:printing/printing.dart'; +import '../widgets/invoice_pdf_preview_page.dart'; +import 'invoice_detail_page.dart'; import '../services/gps_service.dart'; import 'customer_picker_modal.dart'; import 'product_picker_modal.dart'; @@ -170,32 +171,39 @@ class _InvoiceInputFormState extends State { notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", ); - showDialog( - context: context, - builder: (context) => Dialog.fullscreen( - child: Column( - children: [ - AppBar( - title: Text("${invoice.documentTypeName}プレビュー"), - leading: IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)), - ), - Expanded( - child: PdfPreview( - build: (format) async { - // PdfGeneratorを少しリファクタして pw.Document を返す関数に分離することも可能だが - // ここでは generateInvoicePdf の中身を模したバイト生成を行う - // (もしくは generateInvoicePdf のシグネチャを変えてバイトを返すようにする) - // 簡易化のため、一時ファイルを作ってそれを読み込むか、Generatorを修正する - // 今回は Generator に pw.Document を生成する内部関数を作る - final pdfDoc = await buildInvoiceDocument(invoice); - return pdfDoc.save(); - }, - allowPrinting: false, - allowSharing: false, - canChangePageFormat: false, - ), - ), - ], + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => InvoicePdfPreviewPage( + invoice: invoice, + isUnlocked: true, + isLocked: false, + allowFormalIssue: widget.existingInvoice != null && !(widget.existingInvoice?.isLocked ?? false), + onFormalIssue: (widget.existingInvoice != null) + ? () async { + final promoted = invoice.copyWith(isDraft: false); + await _invoiceRepo.saveInvoice(promoted); + final newPath = await generateInvoicePdf(promoted); + final saved = newPath != null ? promoted.copyWith(filePath: newPath) : promoted; + await _invoiceRepo.saveInvoice(saved); + if (!mounted) return false; + Navigator.pop(context); // close preview + Navigator.pop(context); // exit edit screen + await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => InvoiceDetailPage( + invoice: saved, + isUnlocked: true, + ), + ), + ); + return true; + } + : null, + showShare: false, + showEmail: false, + showPrint: false, ), ), ); @@ -223,8 +231,6 @@ class _InvoiceInputFormState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildDocumentTypeSection(), - const SizedBox(height: 16), _buildDateSection(), const SizedBox(height: 16), _buildCustomerSection(), diff --git a/lib/services/customer_repository.dart b/lib/services/customer_repository.dart index 06027b5..ad9dc43 100644 --- a/lib/services/customer_repository.dart +++ b/lib/services/customer_repository.dart @@ -3,6 +3,7 @@ import '../models/customer_model.dart'; import 'database_helper.dart'; import 'package:uuid/uuid.dart'; import 'activity_log_repository.dart'; +import '../models/customer_contact.dart'; class CustomerRepository { final DatabaseHelper _dbHelper = DatabaseHelper(); @@ -10,13 +11,18 @@ class CustomerRepository { Future> getAllCustomers() async { final db = await _dbHelper.database; - final List> maps = await db.query('customers', orderBy: 'display_name ASC'); - + final List> maps = await db.rawQuery(''' + SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email + FROM customers c + LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1 + ORDER BY c.display_name ASC + '''); + if (maps.isEmpty) { await _generateSampleCustomers(); return getAllCustomers(); // 再帰的に読み込み } - + return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); } @@ -40,11 +46,14 @@ class CustomerRepository { Future saveCustomer(Customer customer) async { final db = await _dbHelper.database; - await db.insert( - 'customers', - customer.toMap(), - conflictAlgorithm: ConflictAlgorithm.replace, - ); + await db.transaction((txn) async { + await txn.insert( + 'customers', + customer.toMap(), + conflictAlgorithm: ConflictAlgorithm.replace, + ); + await _upsertActiveContact(txn, customer); + }); await _logRepo.logAction( action: "SAVE_CUSTOMER", @@ -109,13 +118,67 @@ class CustomerRepository { Future> searchCustomers(String query) async { final db = await _dbHelper.database; - final List> maps = await db.query( - 'customers', - where: 'display_name LIKE ? OR formal_name LIKE ?', - whereArgs: ['%$query%', '%$query%'], - orderBy: 'display_name ASC', - limit: 50, - ); + final List> maps = await db.rawQuery(''' + SELECT c.*, cc.address AS contact_address, cc.tel AS contact_tel, cc.email AS contact_email + FROM customers c + LEFT JOIN customer_contacts cc ON cc.customer_id = c.id AND cc.is_active = 1 + WHERE c.display_name LIKE ? OR c.formal_name LIKE ? + ORDER BY c.display_name ASC + LIMIT 50 + ''', ['%$query%', '%$query%']); return List.generate(maps.length, (i) => Customer.fromMap(maps[i])); } + + Future updateContact({required String customerId, String? email, String? tel, String? address}) async { + final db = await _dbHelper.database; + await db.transaction((txn) async { + final nextVersion = await _nextContactVersion(txn, customerId); + await txn.update('customer_contacts', {'is_active': 0}, where: 'customer_id = ?', whereArgs: [customerId]); + await txn.insert('customer_contacts', { + 'id': const Uuid().v4(), + 'customer_id': customerId, + 'email': email, + 'tel': tel, + 'address': address, + 'version': nextVersion, + 'is_active': 1, + 'created_at': DateTime.now().toIso8601String(), + }); + }); + + await _logRepo.logAction( + action: "UPDATE_CUSTOMER_CONTACT", + targetType: "CUSTOMER", + targetId: customerId, + details: "連絡先を更新 (version up)", + ); + } + + Future getActiveContact(String customerId) async { + final db = await _dbHelper.database; + final rows = await db.query('customer_contacts', where: 'customer_id = ? AND is_active = 1', whereArgs: [customerId], limit: 1); + if (rows.isEmpty) return null; + return CustomerContact.fromMap(rows.first); + } + + Future _nextContactVersion(DatabaseExecutor txn, String customerId) async { + final res = await txn.rawQuery('SELECT MAX(version) as v FROM customer_contacts WHERE customer_id = ?', [customerId]); + final current = res.first['v'] as int?; + return (current ?? 0) + 1; + } + + Future _upsertActiveContact(DatabaseExecutor txn, Customer customer) async { + final nextVersion = await _nextContactVersion(txn, customer.id); + await txn.update('customer_contacts', {'is_active': 0}, where: 'customer_id = ?', whereArgs: [customer.id]); + await txn.insert('customer_contacts', { + 'id': const Uuid().v4(), + 'customer_id': customer.id, + 'email': customer.email, + 'tel': customer.tel, + 'address': customer.address, + 'version': nextVersion, + 'is_active': 1, + 'created_at': DateTime.now().toIso8601String(), + }); + } } diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index 682ac4c..a8c1b94 100644 --- a/lib/services/database_helper.dart +++ b/lib/services/database_helper.dart @@ -2,7 +2,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path/path.dart'; class DatabaseHelper { - static const _databaseVersion = 15; + static const _databaseVersion = 17; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -105,6 +105,45 @@ class DatabaseHelper { await _safeAddColumn(db, 'customers', 'is_locked INTEGER DEFAULT 0'); await _safeAddColumn(db, 'products', 'is_locked INTEGER DEFAULT 0'); } + if (oldVersion < 16) { + await db.execute(''' + CREATE TABLE customer_contacts ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL, + email TEXT, + tel TEXT, + address TEXT, + version INTEGER NOT NULL, + is_active INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE + ) + '''); + await db.execute('CREATE INDEX idx_customer_contacts_cust ON customer_contacts(customer_id)'); + + // 既存顧客の連絡先を初期バージョンとしてコピー + final existing = await db.query('customers'); + final now = DateTime.now().toIso8601String(); + for (final row in existing) { + final contactId = "${row['id']}_v1"; + await db.insert('customer_contacts', { + 'id': contactId, + 'customer_id': row['id'], + 'email': null, + 'tel': row['tel'], + 'address': row['address'], + 'version': 1, + 'is_active': 1, + 'created_at': now, + }); + } + } + if (oldVersion < 17) { + await _safeAddColumn(db, 'invoices', 'contact_version_id INTEGER'); + await _safeAddColumn(db, 'invoices', 'contact_email_snapshot TEXT'); + await _safeAddColumn(db, 'invoices', 'contact_tel_snapshot TEXT'); + await _safeAddColumn(db, 'invoices', 'contact_address_snapshot TEXT'); + } } Future _onCreate(Database db, int version) async { @@ -135,6 +174,21 @@ class DatabaseHelper { ) '''); + await db.execute(''' + CREATE TABLE customer_contacts ( + id TEXT PRIMARY KEY, + customer_id TEXT NOT NULL, + email TEXT, + tel TEXT, + address TEXT, + version INTEGER NOT NULL, + is_active INTEGER DEFAULT 1, + created_at TEXT NOT NULL, + FOREIGN KEY(customer_id) REFERENCES customers(id) ON DELETE CASCADE + ) + '''); + await db.execute('CREATE INDEX idx_customer_contacts_cust ON customer_contacts(customer_id)'); + // 商品マスター await db.execute(''' CREATE TABLE products ( @@ -173,6 +227,10 @@ class DatabaseHelper { content_hash TEXT, is_draft INTEGER DEFAULT 0, is_locked INTEGER DEFAULT 0, + contact_version_id INTEGER, + contact_email_snapshot TEXT, + contact_tel_snapshot TEXT, + contact_address_snapshot TEXT, FOREIGN KEY (customer_id) REFERENCES customers (id) ) '''); diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index 1464ca0..5c3c2a9 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -3,6 +3,7 @@ import 'package:sqflite/sqflite.dart'; import 'package:path_provider/path_provider.dart'; import '../models/invoice_models.dart'; import '../models/customer_model.dart'; +import '../models/customer_contact.dart'; import 'database_helper.dart'; import 'activity_log_repository.dart'; @@ -17,6 +18,19 @@ class InvoiceRepository { final Invoice toSave = invoice.isDraft ? invoice : invoice.copyWith(isLocked: true); await db.transaction((txn) async { + // 最新の連絡先をスナップショットする(なければ空) + CustomerContact? activeContact; + final contactRows = await txn.query('customer_contacts', where: 'customer_id = ? AND is_active = 1', whereArgs: [invoice.customer.id]); + if (contactRows.isNotEmpty) { + activeContact = CustomerContact.fromMap(contactRows.first); + } + final Invoice savingWithContact = toSave.copyWith( + contactVersionId: activeContact?.version, + contactEmailSnapshot: activeContact?.email, + contactTelSnapshot: activeContact?.tel, + contactAddressSnapshot: activeContact?.address, + ); + // 在庫の調整(更新の場合、以前の数量を戻してから新しい数量を引く) final List> oldItems = await txn.query( 'invoice_items', @@ -37,7 +51,7 @@ class InvoiceRepository { // 伝票ヘッダーの保存 await txn.insert( 'invoices', - toSave.toMap(), + savingWithContact.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); @@ -126,6 +140,10 @@ class InvoiceRepository { isDraft: (iMap['is_draft'] ?? 0) == 1, subject: iMap['subject'], isLocked: (iMap['is_locked'] ?? 0) == 1, + contactVersionId: iMap['contact_version_id'], + contactEmailSnapshot: iMap['contact_email_snapshot'], + contactTelSnapshot: iMap['contact_tel_snapshot'], + contactAddressSnapshot: iMap['contact_address_snapshot'], )); } return invoices; diff --git a/lib/services/pdf_generator.dart b/lib/services/pdf_generator.dart index 4479d7c..56ef12c 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -14,238 +14,217 @@ import 'activity_log_repository.dart'; Future buildInvoiceDocument(Invoice invoice) async { final pdf = pw.Document(); - // フォントのロード final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf"); final ipaex = pw.Font.ttf(fontData); - final dateFormatter = DateFormat('yyyy年MM月dd日'); final amountFormatter = NumberFormat("#,###"); - // 自社情報の取得 final companyRepo = CompanyRepository(); final companyInfo = await companyRepo.getCompanyInfo(); - // 印影画像のロード pw.MemoryImage? sealImage; if (companyInfo.sealPath != null) { final file = File(companyInfo.sealPath!); if (await file.exists()) { - final bytes = await file.readAsBytes(); - sealImage = pw.MemoryImage(bytes); + sealImage = pw.MemoryImage(await file.readAsBytes()); } } pdf.addPage( pw.MultiPage( - pageFormat: PdfPageFormat.a4, - margin: const pw.EdgeInsets.all(32), - theme: pw.ThemeData.withFont( - base: ipaex, - bold: ipaex, - italic: ipaex, - boldItalic: ipaex, - ).copyWith( - defaultTextStyle: pw.TextStyle(fontFallback: [ipaex]), + pageTheme: pw.PageTheme( + pageFormat: PdfPageFormat.a4, + margin: const pw.EdgeInsets.all(32), + theme: pw.ThemeData.withFont( + base: ipaex, + bold: ipaex, + italic: ipaex, + boldItalic: ipaex, + ).copyWith(defaultTextStyle: pw.TextStyle(fontFallback: [ipaex])), + buildBackground: (context) { + if (!invoice.isDraft) return pw.SizedBox(); + return pw.Center( + child: pw.Transform.rotate( + angle: -0.5, + child: pw.Opacity( + opacity: 0.18, + child: pw.Text( + '下書き', + style: pw.TextStyle( + fontSize: 120, + fontWeight: pw.FontWeight.bold, + color: PdfColors.grey600, + ), + ), + ), + ), + ); + }, ), - build: (context) => [ - // タイトル - pw.Header( - level: 0, - child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + build: (context) { + final content = [ + pw.Header( + level: 0, + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text(invoice.documentTypeName, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text("番号: ${invoice.invoiceNumber}"), + pw.Text("発行日: ${dateFormatter.format(invoice.date)}"), + ], + ), + ], + ), + ), + pw.SizedBox(height: 20), + pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Text(invoice.documentTypeName, style: pw.TextStyle(fontSize: 28, fontWeight: pw.FontWeight.bold)), - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.end, - children: [ - pw.Text("番号: ${invoice.invoiceNumber}"), - pw.Text("発行日: ${dateFormatter.format(invoice.date)}"), - ], + pw.Expanded( + child: pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + pw.Container( + decoration: const pw.BoxDecoration(border: pw.Border(bottom: pw.BorderSide(width: 1))), + child: pw.Text(invoice.customer.invoiceName, style: const pw.TextStyle(fontSize: 18)), + ), + pw.SizedBox(height: 6), + if ((invoice.contactAddressSnapshot ?? invoice.customer.address) != null) + pw.Text(invoice.contactAddressSnapshot ?? invoice.customer.address!, style: const pw.TextStyle(fontSize: 12)), + if ((invoice.contactTelSnapshot ?? invoice.customer.tel) != null) + pw.Text("TEL: ${invoice.contactTelSnapshot ?? invoice.customer.tel}", style: const pw.TextStyle(fontSize: 12)), + if (invoice.contactEmailSnapshot != null) + pw.Text("MAIL: ${invoice.contactEmailSnapshot}", style: const pw.TextStyle(fontSize: 12)), + pw.SizedBox(height: 10), + pw.Text( + invoice.documentType == DocumentType.receipt + ? "上記の金額を正に領収いたしました。" + : (invoice.documentType == DocumentType.estimation + ? "下記の通り、お見積り申し上げます。" + : "下記の通り、ご請求申し上げます。"), + ), + ], + ), + ), + pw.Expanded( + child: pw.Stack( + alignment: pw.Alignment.topRight, + children: [ + pw.Column( + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), + if (companyInfo.zipCode != null) pw.Text("〒${companyInfo.zipCode}"), + if (companyInfo.address != null) pw.Text(companyInfo.address!), + if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"), + if (companyInfo.registrationNumber != null && companyInfo.registrationNumber!.isNotEmpty) + pw.Text("登録番号: ${companyInfo.registrationNumber!}", style: const pw.TextStyle(fontSize: 10)), + ], + ), + if (sealImage != null) + pw.Positioned( + right: 10, + top: 0, + child: pw.Opacity(opacity: 0.8, child: pw.Image(sealImage, width: 40, height: 40)), + ), + ], + ), ), ], ), - ), - pw.SizedBox(height: 20), - - // 宛名と自社情報 - pw.Row( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Expanded( - child: pw.Column( + pw.SizedBox(height: 30), + pw.Container( + padding: const pw.EdgeInsets.all(8), + decoration: const pw.BoxDecoration(color: PdfColors.grey200), + child: pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + children: [ + pw.Text( + invoice.documentType == DocumentType.receipt + ? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)") + : (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"), + style: const pw.TextStyle(fontSize: 16), + ), + pw.Text("¥${amountFormatter.format(invoice.totalAmount)} -", style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)), + ], + ), + ), + pw.SizedBox(height: 20), + pw.Table.fromTextArray( + headers: const ["品名", "数量", "単価", "金額"], + data: invoice.items + .map((item) => [ + item.description, + item.quantity.toString(), + amountFormatter.format(item.unitPrice), + amountFormatter.format(item.subtotal), + ]) + .toList(), + headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold, font: ipaex), + headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300), + cellAlignment: pw.Alignment.centerLeft, + columnWidths: const {0: pw.FlexColumnWidth(3), 1: pw.FlexColumnWidth(1), 2: pw.FlexColumnWidth(2), 3: pw.FlexColumnWidth(2)}, + ), + pw.SizedBox(height: 20), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.end, + children: [ + pw.Container( + width: 200, + child: pw.Column( + children: [ + pw.SizedBox(height: 10), + _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), + if (companyInfo.taxDisplayMode == 'normal') + _buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)), + if (companyInfo.taxDisplayMode == 'text_only') _buildSummaryRow("消費税", "(税別)"), + pw.Divider(), + _buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true), + ], + ), + ), + ], + ), + if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[ + pw.SizedBox(height: 10), + pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)), + pw.Container( + width: double.infinity, + padding: const pw.EdgeInsets.all(8), + decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)), + child: pw.Text(invoice.notes!), + ), + ], + pw.SizedBox(height: 20), + pw.Row( + mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, + crossAxisAlignment: pw.CrossAxisAlignment.end, + children: [ + pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ - pw.Container( - decoration: const pw.BoxDecoration( - border: pw.Border(bottom: pw.BorderSide(width: 1)), - ), - child: pw.Text(invoice.customer.invoiceName, - style: const pw.TextStyle(fontSize: 18)), - ), - pw.SizedBox(height: 10), - pw.Text(invoice.documentType == DocumentType.receipt - ? "上記の金額を正に領収いたしました。" - : (invoice.documentType == DocumentType.estimation - ? "下記の通り、お見積り申し上げます。" - : "下記の通り、ご請求申し上げます。")), + pw.Text("Verification Hash (SHA256):", style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700)), + pw.Text(invoice.contentHash, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)), ], ), - ), - pw.Expanded( - child: pw.Stack( - alignment: pw.Alignment.topRight, - children: [ - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.end, - children: [ - pw.Text(companyInfo.name, style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)), - if (companyInfo.zipCode != null) pw.Text("〒${companyInfo.zipCode}"), - if (companyInfo.address != null) pw.Text(companyInfo.address!), - if (companyInfo.tel != null) pw.Text("TEL: ${companyInfo.tel}"), - if (companyInfo.registrationNumber != null && companyInfo.registrationNumber!.isNotEmpty) - pw.Text("登録番号: ${companyInfo.registrationNumber!}", style: const pw.TextStyle(fontSize: 10)), - ], - ), - if (sealImage != null) - pw.Positioned( - right: 10, - top: 0, - child: pw.Opacity( - opacity: 0.8, - child: pw.Image(sealImage, width: 40, height: 40), - ), - ), - ], + pw.Container( + width: 50, + height: 50, + child: pw.BarcodeWidget(barcode: pw.Barcode.qrCode(), data: invoice.contentHash, drawText: false), ), - ), - ], - ), - pw.SizedBox(height: 30), - - // 合計金額表示 - pw.Container( - padding: const pw.EdgeInsets.all(8), - decoration: const pw.BoxDecoration(color: PdfColors.grey200), - child: pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - children: [ - pw.Text( - invoice.documentType == DocumentType.receipt - ? (companyInfo.taxDisplayMode == 'hidden' ? "領収金額" : "領収金額 (税込)") - : (companyInfo.taxDisplayMode == 'hidden' ? "合計金額" : "合計金額 (税込)"), - style: const pw.TextStyle(fontSize: 16)), - pw.Text("¥${amountFormatter.format(invoice.totalAmount)} -", - style: pw.TextStyle(fontSize: 20, fontWeight: pw.FontWeight.bold)), ], ), - ), - pw.SizedBox(height: 20), + ]; - // 明細テーブル - // 明細テーブル - pw.Table( - border: pw.TableBorder.all(color: PdfColors.grey300), - columnWidths: { - 0: const pw.FlexColumnWidth(4), - 1: const pw.FixedColumnWidth(50), - 2: const pw.FixedColumnWidth(80), - 3: const pw.FixedColumnWidth(80), - }, - children: [ - // ヘッダー - pw.TableRow( - decoration: const pw.BoxDecoration(color: PdfColors.grey300), - children: [ - pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("品名 / 項目", style: pw.TextStyle(fontWeight: pw.FontWeight.bold))), - pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("数量", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)), - pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("単価", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)), - pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("金額", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)), - ], - ), - // データ行 - ...invoice.items.map((item) { - return pw.TableRow( - children: [ - pw.Padding( - padding: const pw.EdgeInsets.all(4), - child: _parseMarkdown(item.description), - ), - pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(item.quantity.toString(), textAlign: pw.TextAlign.right)), - pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.unitPrice), textAlign: pw.TextAlign.right)), - pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text(amountFormatter.format(item.subtotal), textAlign: pw.TextAlign.right)), - ], - ); - }), - ], - ), - - // 計算内訳 - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.end, - children: [ - pw.Container( - width: 200, - child: pw.Column( - children: [ - pw.SizedBox(height: 10), - _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), - if (companyInfo.taxDisplayMode == 'normal') - _buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)), - if (companyInfo.taxDisplayMode == 'text_only') - _buildSummaryRow("消費税", "(税別)"), - pw.Divider(), - _buildSummaryRow("合計", "¥${amountFormatter.format(invoice.totalAmount)}", isBold: true), - ], - ), - ), - ], - ), - - // 備考 - // 備考 - if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[ - pw.SizedBox(height: 10), - pw.Text("備考:", style: pw.TextStyle(fontWeight: pw.FontWeight.bold)), - pw.Container( - width: double.infinity, - padding: const pw.EdgeInsets.all(8), - decoration: pw.BoxDecoration(border: pw.Border.all(color: PdfColors.grey400)), - child: pw.Text(invoice.notes!), - ), - ], - - pw.SizedBox(height: 20), - // 監査用ハッシュとQRコード - pw.Row( - mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, - crossAxisAlignment: pw.CrossAxisAlignment.end, - children: [ - pw.Column( - crossAxisAlignment: pw.CrossAxisAlignment.start, - children: [ - pw.Text("Verification Hash (SHA256):", style: pw.TextStyle(fontSize: 8, color: PdfColors.grey700)), - pw.Text(invoice.contentHash, style: pw.TextStyle(fontSize: 10, fontWeight: pw.FontWeight.bold, color: PdfColors.grey700)), - ], - ), - pw.Container( - width: 50, - height: 50, - child: pw.BarcodeWidget( - barcode: pw.Barcode.qrCode(), - data: invoice.contentHash, - drawText: false, - ), - ), - ], - ), - ], + return [pw.Column(children: content)]; + }, footer: (context) => pw.Container( alignment: pw.Alignment.centerRight, margin: const pw.EdgeInsets.only(top: 16), - child: pw.Text( - "Page ${context.pageNumber} / ${context.pagesCount}", - style: const pw.TextStyle(color: PdfColors.grey), - ), + child: pw.Text("Page ${context.pageNumber} / ${context.pagesCount}", style: const pw.TextStyle(color: PdfColors.grey)), ), ), ); diff --git a/lib/widgets/invoice_pdf_preview_page.dart b/lib/widgets/invoice_pdf_preview_page.dart new file mode 100644 index 0000000..7cf0a13 --- /dev/null +++ b/lib/widgets/invoice_pdf_preview_page.dart @@ -0,0 +1,117 @@ +import 'dart:typed_data'; +import 'package:flutter/material.dart'; +import 'package:printing/printing.dart'; +import '../models/invoice_models.dart'; +import '../services/pdf_generator.dart'; + +class InvoicePdfPreviewPage extends StatelessWidget { + final Invoice invoice; + final bool allowFormalIssue; + final bool isUnlocked; + final bool isLocked; + final Future Function()? onFormalIssue; + final bool showShare; + final bool showEmail; + final bool showPrint; + + const InvoicePdfPreviewPage({ + Key? key, + required this.invoice, + this.allowFormalIssue = true, + this.isUnlocked = false, + this.isLocked = false, + this.onFormalIssue, + this.showShare = true, + this.showEmail = true, + this.showPrint = true, + }) : super(key: key); + + Future _buildPdfBytes() async { + final doc = await buildInvoiceDocument(invoice); + return Uint8List.fromList(await doc.save()); + } + + @override + Widget build(BuildContext context) { + final isDraft = invoice.isDraft; + return Scaffold( + appBar: AppBar(title: const Text("PDFプレビュー")), + body: Column( + children: [ + Expanded( + child: PdfPreview( + build: (format) async => await _buildPdfBytes(), + allowPrinting: false, + allowSharing: false, + canChangePageFormat: false, + canChangeOrientation: false, + canDebug: false, + actions: const [], + ), + ), + SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 16), + child: Row( + children: [ + Expanded( + child: ElevatedButton.icon( + onPressed: (allowFormalIssue && isDraft && isUnlocked && !isLocked && onFormalIssue != null) + ? () async { + final ok = await onFormalIssue!(); + if (ok && context.mounted) Navigator.pop(context, true); + } + : null, + icon: const Icon(Icons.check_circle_outline), + label: const Text("正式発行"), + style: ElevatedButton.styleFrom(backgroundColor: Colors.orange, foregroundColor: Colors.white), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: showShare + ? () async { + final bytes = await _buildPdfBytes(); + await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf'); + } + : null, + icon: const Icon(Icons.share), + label: const Text("共有"), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: showEmail + ? () async { + final bytes = await _buildPdfBytes(); + await Printing.sharePdf(bytes: bytes, filename: 'invoice.pdf', subject: '請求書送付'); + } + : null, + icon: const Icon(Icons.mail_outline), + label: const SizedBox.shrink(), + ), + ), + const SizedBox(width: 8), + Expanded( + child: ElevatedButton.icon( + onPressed: showPrint + ? () async { + await Printing.layoutPdf(onLayout: (format) async => await _buildPdfBytes()); + } + : null, + icon: const Icon(Icons.print), + label: const SizedBox.shrink(), + ), + ), + ], + ), + ), + ) + ], + ), + ); + } +} diff --git a/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/macos/Flutter/ephemeral/Flutter-Generated.xcconfig index 727e853..c4155b2 100644 --- a/macos/Flutter/ephemeral/Flutter-Generated.xcconfig +++ b/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -3,8 +3,8 @@ FLUTTER_ROOT=/home/user/development/flutter FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0 COCOAPODS_PARALLEL_CODE_SIGN=true FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=1.5.0 -FLUTTER_BUILD_NUMBER=150 +FLUTTER_BUILD_NAME=1.5.06 +FLUTTER_BUILD_NUMBER=151 DART_OBFUSCATION=false TRACK_WIDGET_CREATION=true TREE_SHAKE_ICONS=false diff --git a/macos/Flutter/ephemeral/flutter_export_environment.sh b/macos/Flutter/ephemeral/flutter_export_environment.sh index 5c7456c..85319c8 100755 --- a/macos/Flutter/ephemeral/flutter_export_environment.sh +++ b/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -4,8 +4,8 @@ export "FLUTTER_ROOT=/home/user/development/flutter" export "FLUTTER_APPLICATION_PATH=/home/user/dev/h-1.flutter.0" export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=1.5.0" -export "FLUTTER_BUILD_NUMBER=150" +export "FLUTTER_BUILD_NAME=1.5.06" +export "FLUTTER_BUILD_NUMBER=151" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=true" export "TREE_SHAKE_ICONS=false"