import 'package:flutter/material.dart'; import 'package:uuid/uuid.dart'; import 'package:intl/intl.dart'; import '../models/customer_model.dart'; 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 '../services/gps_service.dart'; import 'customer_picker_modal.dart'; import 'product_picker_modal.dart'; import '../models/company_model.dart'; import '../services/company_repository.dart'; class InvoiceInputForm extends StatefulWidget { final Function(Invoice invoice, String filePath) onInvoiceGenerated; const InvoiceInputForm({ Key? key, required this.onInvoiceGenerated, }) : super(key: key); @override State createState() => _InvoiceInputFormState(); } class _InvoiceInputFormState extends State { final _repository = InvoiceRepository(); Customer? _selectedCustomer; final List _items = []; double _taxRate = 0.10; bool _includeTax = true; CompanyInfo? _companyInfo; DocumentType _documentType = DocumentType.invoice; // 追加 String _status = "取引先と商品を入力してください"; // 署名用の実験的パス List _signaturePath = []; @override void initState() { super.initState(); _loadInitialData(); } Future _loadInitialData() async { _repository.cleanupOrphanedPdfs(); final customerRepo = CustomerRepository(); final customers = await customerRepo.getAllCustomers(); if (customers.isNotEmpty) { setState(() => _selectedCustomer = customers.first); } final companyRepo = CompanyRepository(); final companyInfo = await companyRepo.getCompanyInfo(); setState(() { _companyInfo = companyInfo; _taxRate = companyInfo.defaultTaxRate; }); } void _addItem() { showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => ProductPickerModal( onItemSelected: (item) { setState(() => _items.add(item)); Navigator.pop(context); }, ), ); } int get _subTotal => _items.fold(0, (sum, item) => sum + (item.unitPrice * item.quantity)); int get _tax => _includeTax ? (_subTotal * _taxRate).round() : 0; int get _total => _subTotal + _tax; Future _saveInvoice({bool generatePdf = true}) async { if (_selectedCustomer == null) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください"))); return; } if (_items.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("明細を1件以上入力してください"))); return; } // GPS情報の取得 final gpsService = GpsService(); final pos = await gpsService.getCurrentLocation(); if (pos != null) { await gpsService.logLocation(); // 履歴テーブルにも保存 } final invoice = Invoice( customer: _selectedCustomer!, date: DateTime.now(), items: _items, taxRate: _includeTax ? _taxRate : 0.0, documentType: _documentType, customerFormalNameSnapshot: _selectedCustomer!.formalName, notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", latitude: pos?.latitude, longitude: pos?.longitude, ); if (generatePdf) { setState(() => _status = "PDFを生成中..."); final path = await generateInvoicePdf(invoice); if (path != null) { final updatedInvoice = invoice.copyWith(filePath: path); await _repository.saveInvoice(updatedInvoice); widget.onInvoiceGenerated(updatedInvoice, path); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存し、PDFを生成しました"))); } } else { await _repository.saveInvoice(invoice); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("伝票を保存しました(PDF未生成)"))); Navigator.pop(context); // 入力を閉じる } } void _showPreview() { if (_selectedCustomer == null) return; final invoice = Invoice( customer: _selectedCustomer!, date: DateTime.now(), items: _items, taxRate: _includeTax ? _taxRate : 0.0, documentType: _documentType, customerFormalNameSnapshot: _selectedCustomer!.formalName, 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, ), ), ], ), ), ); } @override Widget build(BuildContext context) { final fmt = NumberFormat("#,###"); return Column( children: [ Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _buildDocumentTypeSection(), // 追加 const SizedBox(height: 16), _buildCustomerSection(), const SizedBox(height: 20), _buildItemsSection(fmt), const SizedBox(height: 20), _buildExperimentalSection(), const SizedBox(height: 20), _buildSummarySection(fmt), const SizedBox(height: 20), _buildSignatureSection(), ], ), ), ), _buildBottomActionBar(), ], ); } Widget _buildDocumentTypeSection() { return Container( padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: Colors.grey.shade200, borderRadius: BorderRadius.circular(12), ), child: Row( children: DocumentType.values.map((type) { final isSelected = _documentType == type; String label = ""; IconData icon = Icons.description; switch (type) { case DocumentType.estimation: label = "見積"; icon = Icons.article_outlined; break; case DocumentType.delivery: label = "納品"; icon = Icons.local_shipping_outlined; break; case DocumentType.invoice: label = "請求"; icon = Icons.receipt_long_outlined; break; case DocumentType.receipt: label = "領収"; icon = Icons.payments_outlined; break; } return Expanded( child: InkWell( onTap: () => setState(() => _documentType = type), child: Container( padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected ? Colors.indigo : Colors.transparent, borderRadius: BorderRadius.circular(8), ), child: Column( children: [ Icon(icon, color: isSelected ? Colors.white : Colors.grey.shade600, size: 20), Text(label, style: TextStyle( color: isSelected ? Colors.white : Colors.grey.shade600, fontSize: 12, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, )), ], ), ), ), ); }).toList(), ), ); } Widget _buildCustomerSection() { return Card( elevation: 0, color: Colors.blueGrey.shade50, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ListTile( leading: const Icon(Icons.business, color: Colors.blueGrey), title: Text(_selectedCustomer?.formalName ?? "取引先を選択してください", style: TextStyle(color: _selectedCustomer == null ? Colors.grey : Colors.black87, fontWeight: FontWeight.bold)), subtitle: const Text("請求先マスターから選択"), trailing: const Icon(Icons.chevron_right), onTap: () async { await showModalBottomSheet( context: context, isScrollControlled: true, builder: (context) => FractionallySizedBox( heightFactor: 0.9, child: CustomerPickerModal(onCustomerSelected: (c) { setState(() => _selectedCustomer = c); Navigator.pop(context); }), ), ); }, ), ); } Widget _buildItemsSection(NumberFormat fmt) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text("明細項目", style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), TextButton.icon(onPressed: _addItem, icon: const Icon(Icons.add), label: const Text("追加")), ], ), if (_items.isEmpty) const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: Text("商品が追加されていません", style: TextStyle(color: Colors.grey))), ) else ..._items.asMap().entries.map((entry) { final idx = entry.key; final item = entry.value; return Card( margin: const EdgeInsets.only(bottom: 8), child: ListTile( title: Text(item.description), subtitle: Text("¥${fmt.format(item.unitPrice)} x ${item.quantity}"), trailing: Row( mainAxisSize: MainAxisSize.min, children: [ Text("¥${fmt.format(item.unitPrice * item.quantity)}", style: const TextStyle(fontWeight: FontWeight.bold)), IconButton( icon: const Icon(Icons.remove_circle_outline, color: Colors.redAccent), onPressed: () => setState(() => _items.removeAt(idx)), ), ], ), ), ); }), ], ); } Widget _buildExperimentalSection() { return Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(12)), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text("実験的オプション", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)), const SizedBox(height: 8), Row( children: [ const Text("消費税: "), ChoiceChip( label: const Text("10%"), selected: _taxRate == 0.10, onSelected: (val) => setState(() => _taxRate = 0.10), ), const SizedBox(width: 8), ChoiceChip( label: const Text("8%"), selected: _taxRate == 0.08, onSelected: (val) => setState(() => _taxRate = 0.08), ), const Spacer(), Switch( value: _includeTax, onChanged: (val) => setState(() => _includeTax = val), ), Text(_includeTax ? "税込表示" : "非課税"), ], ), ], ), ); } Widget _buildSummarySection(NumberFormat fmt) { return Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.indigo.shade900, borderRadius: BorderRadius.circular(12)), child: Column( children: [ _buildSummaryRow("小計 (税抜)", "¥${fmt.format(_subTotal)}", Colors.white70), if (_companyInfo?.taxDisplayMode == 'normal') _buildSummaryRow("消費税 (${(_taxRate * 100).toInt()}%)", "¥${fmt.format(_tax)}", Colors.white70), if (_companyInfo?.taxDisplayMode == 'text_only') _buildSummaryRow("消費税", "(税別)", Colors.white70), const Divider(color: Colors.white24), _buildSummaryRow("合計金額", "¥${fmt.format(_total)}", Colors.white, fontSize: 24), ], ), ); } Widget _buildSummaryRow(String label, String value, Color color, {double fontSize = 16}) { return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: TextStyle(color: color, fontSize: fontSize)), Text(value, style: TextStyle(color: color, fontSize: fontSize, fontWeight: FontWeight.bold)), ], ), ); } Widget _buildSignatureSection() { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ const Text("手書き署名 (実験的)", style: TextStyle(fontWeight: FontWeight.bold)), TextButton(onPressed: () => setState(() => _signaturePath.clear()), child: const Text("クリア")), ], ), Container( height: 150, width: double.infinity, decoration: BoxDecoration( color: Colors.white, border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), ), child: GestureDetector( onPanUpdate: (details) { setState(() { RenderBox renderBox = context.findRenderObject() as RenderBox; _signaturePath.add(renderBox.globalToLocal(details.globalPosition)); }); }, onPanEnd: (details) => _signaturePath.add(null), child: CustomPaint( painter: SignaturePainter(_signaturePath), size: Size.infinite, ), ), ), ], ); } Widget _buildBottomActionBar() { return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20), decoration: BoxDecoration( color: Colors.white, boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))], ), child: SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: OutlinedButton.icon( onPressed: _showPreview, icon: const Icon(Icons.remove_red_eye), label: const Text("仮表示"), style: OutlinedButton.styleFrom( padding: const EdgeInsets.symmetric(vertical: 16), side: const BorderSide(color: Colors.indigo), ), ), ), const SizedBox(width: 8), Expanded( child: ElevatedButton.icon( onPressed: () => _saveInvoice(generatePdf: false), icon: const Icon(Icons.save), label: const Text("保存のみ"), style: ElevatedButton.styleFrom( backgroundColor: Colors.blueGrey, foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 16), ), ), ), ], ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: () => _saveInvoice(generatePdf: true), icon: const Icon(Icons.picture_as_pdf), label: const Text("確定してPDF生成"), style: ElevatedButton.styleFrom( minimumSize: const Size(double.infinity, 56), backgroundColor: Colors.indigo, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), ), ], ), ), ); } } class SignaturePainter extends CustomPainter { final List points; SignaturePainter(this.points); @override void paint(Canvas canvas, Size size) { Paint paint = Paint() ..color = Colors.black ..strokeCap = StrokeCap.round ..strokeWidth = 3.0; for (int i = 0; i < points.length - 1; i++) { if (points[i] != null && points[i + 1] != null) { canvas.drawLine(points[i]!, points[i + 1]!, paint); } } } @override bool shouldRepaint(SignaturePainter oldDelegate) => true; }