// lib/services/pdf_generator.dart import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart' show debugPrint; import 'package:flutter/services.dart'; import 'package:pdf/pdf.dart'; 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を生成し、保存する /// 見積書、納品書、請求書、領収書の各DocumentTypeに対応 Future generateInvoicePdf(Invoice invoice) async { try { final pdf = pw.Document(); // フォントのロード final fontData = await rootBundle.load("assets/fonts/ipaexg.ttf"); 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 String docTitle = invoice.type.label; final String honorific = " 御中"; // 宛名の敬称 (estimateでもinvoiceでも共通化) pdf.addPage( pw.MultiPage( pageFormat: PdfPageFormat.a4, margin: const pw.EdgeInsets.all(32), theme: pw.ThemeData.withFont(base: ttf, bold: boldTtf), build: (context) => [ // タイトル pw.Header( level: 0, child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ 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("発行日: ${dateFormatter.format(invoice.date)}"), ], ), ], ), ), pw.SizedBox(height: 20), // 宛名と自社情報 pw.Row( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.start, children: [ 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!), ), pw.SizedBox(height: 10), pw.Text(invoice.type == DocumentType.estimate ? "下記の通り、御見積申し上げます。" : "下記の通り、ご請求申し上げます。"), ], ), ), pw.Expanded( child: pw.Column( crossAxisAlignment: pw.CrossAxisAlignment.end, children: [ 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! ), ], ), ), ], ), 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("${docTitle}金額合計 (税込)", 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.TableHelper.fromTextArray( headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold), headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300), cellHeight: 30, cellAlignments: { 0: pw.Alignment.centerLeft, 1: pw.Alignment.centerRight, 2: pw.Alignment.centerRight, 3: pw.Alignment.centerRight, }, headers: ["品名 / 項目", "数量", "単価", "金額"], data: List>.generate( invoice.items.length, (index) { final item = invoice.items[index]; return [ item.description, item.quantity.toString(), amountFormatter.format(item.unitPrice), amountFormatter.format(item.subtotal), ]; }, ), ), // 計算内訳 pw.Row( mainAxisAlignment: pw.MainAxisAlignment.end, children: [ pw.Container( width: 200, child: pw.Column( children: [ pw.SizedBox(height: 10), _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), _buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)), pw.Divider(), _buildSummaryRow("合計", amountFormatter.format(invoice.totalAmount), isBold: true), ], ), ), ], ), // 備考 if (invoice.notes != null && invoice.notes!.isNotEmpty) ...[ pw.SizedBox(height: 40), 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!)), ], ], 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), ), ), ), ); 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.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"); return null; } } /// ポケットサーマルプリンタ向けの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( padding: const pw.EdgeInsets.symmetric(vertical: 2), child: pw.Row( mainAxisAlignment: pw.MainAxisAlignment.spaceBetween, children: [ pw.Text(label, style: style), pw.Text(value, style: style), ], ), ); }