diff --git a/lib/models/invoice_models.dart b/lib/models/invoice_models.dart index 7447df1..4587d57 100644 --- a/lib/models/invoice_models.dart +++ b/lib/models/invoice_models.dart @@ -66,6 +66,7 @@ class Invoice { final double? longitude; // 追加 final String terminalId; // 追加: 端末識別子 final bool isDraft; // 追加: 下書きフラグ + final String? subject; // 追加: 案件名 Invoice({ String? id, @@ -84,13 +85,14 @@ class Invoice { this.longitude, // 追加 String? terminalId, // 追加 this.isDraft = false, // 追加: デフォルトは通常 + this.subject, // 追加: 案件 }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), terminalId = terminalId ?? "T1", // デフォルト端末ID updatedAt = updatedAt ?? DateTime.now(); /// 伝票内容から決定論的なハッシュを生成する (SHA256の一部) String get contentHash { - final input = "$id|$terminalId|${date.toIso8601String()}|${customer.id}|$totalAmount|${items.map((e) => "${e.description}${e.quantity}${e.unitPrice}").join()}"; + final input = "$id|$terminalId|${date.toIso8601String()}|${customer.id}|$totalAmount|${subject ?? ""}|${items.map((e) => "${e.description}${e.quantity}${e.unitPrice}").join()}"; final bytes = utf8.encode(input); return sha256.convert(bytes).toString().substring(0, 8).toUpperCase(); } @@ -141,6 +143,7 @@ class Invoice { 'terminal_id': terminalId, // 追加 'content_hash': contentHash, // 追加 'is_draft': isDraft ? 1 : 0, // 追加 + 'subject': subject, // 追加 }; } @@ -161,6 +164,7 @@ class Invoice { double? longitude, String? terminalId, bool? isDraft, + String? subject, }) { return Invoice( id: id ?? this.id, @@ -179,6 +183,7 @@ class Invoice { longitude: longitude ?? this.longitude, terminalId: terminalId ?? this.terminalId, isDraft: isDraft ?? this.isDraft, + subject: subject ?? this.subject, ); } diff --git a/lib/screens/invoice_detail_page.dart b/lib/screens/invoice_detail_page.dart index 5792e77..ff384f3 100644 --- a/lib/screens/invoice_detail_page.dart +++ b/lib/screens/invoice_detail_page.dart @@ -328,6 +328,11 @@ class _InvoiceDetailPageState extends State { Text("取引先:", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)), Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor)), + if (_currentInvoice.subject?.isNotEmpty ?? false) ...[ + const SizedBox(height: 8), + Text("件名: ${_currentInvoice.subject}", + style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Colors.indigoAccent)), + ], if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty) Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)), if (_currentInvoice.latitude != null) ...[ diff --git a/lib/screens/invoice_history_screen.dart b/lib/screens/invoice_history_screen.dart index 62491d0..e71b62e 100644 --- a/lib/screens/invoice_history_screen.dart +++ b/lib/screens/invoice_history_screen.dart @@ -259,14 +259,26 @@ class _InvoiceHistoryScreenState extends State { backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200, child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey), ), - title: Text(invoice.customerNameForDisplay), + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(invoice.customerNameForDisplay, style: const TextStyle(fontWeight: FontWeight.bold)), + if (invoice.subject?.isNotEmpty ?? false) + Text( + invoice.subject!, + style: TextStyle(fontSize: 13, color: Colors.indigo.shade700, fontWeight: FontWeight.normal), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), 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)), + style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), if (invoice.isSynced) const Icon(Icons.sync, size: 16, color: Colors.green) else diff --git a/lib/screens/invoice_input_screen.dart b/lib/screens/invoice_input_screen.dart index 9c9d91f..865a5b6 100644 --- a/lib/screens/invoice_input_screen.dart +++ b/lib/screens/invoice_input_screen.dart @@ -35,6 +35,7 @@ class _InvoiceInputFormState extends State { DocumentType _documentType = DocumentType.invoice; // 追加 DateTime _selectedDate = DateTime.now(); // 追加: 伝票日付 bool _isDraft = false; // 追加: 下書きモード + final TextEditingController _subjectController = TextEditingController(); // 追加 String _status = "取引先と商品を入力してください"; // 署名用の実験的パス @@ -98,14 +99,16 @@ class _InvoiceInputFormState extends State { final invoice = Invoice( customer: _selectedCustomer!, - date: _selectedDate, // 修正 + date: _selectedDate, items: _items, taxRate: _includeTax ? _taxRate : 0.0, documentType: _documentType, customerFormalNameSnapshot: _selectedCustomer!.formalName, + subject: _subjectController.text.isNotEmpty ? _subjectController.text : null, // 追加 notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", latitude: pos?.latitude, longitude: pos?.longitude, + isDraft: _isDraft, // 追加 ); if (generatePdf) { @@ -177,7 +180,7 @@ class _InvoiceInputFormState extends State { backgroundColor: themeColor, appBar: AppBar( leading: const BackButton(), - title: Text(_isDraft ? "伝票作成 (下書きモード)" : "販売アシスト1号 V1.5.02"), + title: Text(_isDraft ? "伝票作成 (下書きモード)" : "販売アシスト1号 V1.5.03"), backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey, ), body: Column( @@ -195,6 +198,8 @@ class _InvoiceInputFormState extends State { _buildDateSection(), const SizedBox(height: 16), _buildCustomerSection(), + const SizedBox(height: 16), + _buildSubjectSection(textColor), // 追加 const SizedBox(height: 20), _buildItemsSection(fmt), const SizedBox(height: 20), @@ -516,30 +521,28 @@ class _InvoiceInputFormState extends State { } Widget _buildDraftToggle() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: _isDraft ? Colors.black26 : Colors.orange.shade50, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: _isDraft ? Colors.orangeAccent : Colors.orange, width: 2), - ), - child: Row( - children: [ - Icon(_isDraft ? Icons.drafts : Icons.check_circle, color: Colors.orange), - const SizedBox(width: 12), - Expanded( - child: Text( - _isDraft ? "下書きモード設定中" : "正式発行モード", - style: TextStyle(fontWeight: FontWeight.bold, color: _isDraft ? Colors.white : Colors.orange.shade900), - ), + // ... (existing code omitted for brevity but I'll provide the new method below it) + } + + Widget _buildSubjectSection(Color textColor) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("案件名 / 件名", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)), + const SizedBox(height: 8), + TextField( + controller: _subjectController, + style: TextStyle(color: textColor), + decoration: InputDecoration( + hintText: "例:事務所改修工事 / 〇〇月分リース料", + hintStyle: TextStyle(color: textColor.withOpacity(0.5)), + filled: true, + fillColor: _isDraft ? Colors.white12 : Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), ), - Switch( - value: _isDraft, - activeColor: Colors.orangeAccent, - onChanged: (val) => setState(() => _isDraft = val), - ), - ], - ), + ), + ], ); } } diff --git a/lib/services/database_helper.dart b/lib/services/database_helper.dart index dc7232b..f2246e9 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 = 11; + static const _databaseVersion = 12; static final DatabaseHelper _instance = DatabaseHelper._internal(); static Database? _database; @@ -91,6 +91,9 @@ class DatabaseHelper { if (oldVersion < 11) { await db.execute('ALTER TABLE invoices ADD COLUMN is_draft INTEGER DEFAULT 0'); } + if (oldVersion < 12) { + await db.execute('ALTER TABLE invoices ADD COLUMN subject TEXT'); + } } Future _onCreate(Database db, int version) async { diff --git a/lib/services/invoice_repository.dart b/lib/services/invoice_repository.dart index 50d769e..d1e3234 100644 --- a/lib/services/invoice_repository.dart +++ b/lib/services/invoice_repository.dart @@ -113,6 +113,7 @@ class InvoiceRepository { longitude: iMap['longitude'], terminalId: iMap['terminal_id'] ?? "T1", isDraft: (iMap['is_draft'] ?? 0) == 1, + subject: iMap['subject'], )); } return invoices; diff --git a/lib/services/pdf_generator.dart b/lib/services/pdf_generator.dart index 2acbf6a..2c55293 100644 --- a/lib/services/pdf_generator.dart +++ b/lib/services/pdf_generator.dart @@ -134,29 +134,41 @@ Future buildInvoiceDocument(Invoice invoice) async { 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, + // 明細テーブル + 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), }, - 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), - ]; - }, - ), + 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)), + ], + ); + }), + ], ), // 計算内訳 @@ -239,8 +251,12 @@ Future generateInvoicePdf(Invoice invoice) async { final pdf = await buildInvoiceDocument(invoice); final String hash = invoice.contentHash; - final String timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); - String fileName = "${invoice.invoiceNumberPrefix}_${invoice.terminalId}_${invoice.id.substring(invoice.id.length - 4)}_${timeStr}_$hash.pdf"; + final String dateStr = DateFormat('yyyyMMdd').format(invoice.date); + final String amountStr = NumberFormat("#,###").format(invoice.totalAmount); + final String subjectStr = invoice.subject?.isNotEmpty == true ? "_${invoice.subject}" : ""; + + // {日付}({タイプ}){顧客名}_{案件}_{金額}_{HASH下8桁}.pdf + String fileName = "${dateStr}(${invoice.documentTypeName})${invoice.customerNameForDisplay}${subjectStr}_${amountStr}円_$hash.pdf"; final directory = await getExternalStorageDirectory(); if (directory == null) return null; @@ -278,3 +294,49 @@ pw.Widget _buildSummaryRow(String label, String value, {bool isBold = false}) { ), ); } + +pw.Widget _parseMarkdown(String text) { + final lines = text.split('\n'); + final List widgets = []; + + for (final line in lines) { + String content = line; + pw.EdgeInsets padding = const pw.EdgeInsets.only(bottom: 2); + pw.Widget? prefix; + + // 箇条書き / インデント + if (content.startsWith('* ') || content.startsWith('- ')) { + content = content.substring(2); + prefix = pw.Padding(padding: const pw.EdgeInsets.only(right: 4), child: pw.Text('•')); + } else if (content.startsWith(' ')) { + padding = padding.copyWith(left: 10); + } + + // 太字 (**text**) - 簡易実装 + final List spans = []; + final parts = content.split('**'); + for (int i = 0; i < parts.length; i++) { + spans.add(pw.TextSpan( + text: parts[i], + style: i % 2 == 1 ? pw.TextStyle(fontWeight: pw.FontWeight.bold) : null, + )); + } + + widgets.add( + pw.Padding( + padding: padding, + child: pw.Row( + crossAxisAlignment: pw.CrossAxisAlignment.start, + children: [ + if (prefix != null) prefix, + pw.Expanded( + child: pw.RichText(text: pw.TextSpan(children: spans, style: const pw.TextStyle(fontSize: 10))), + ), + ], + ), + ), + ); + } + + return pw.Column(crossAxisAlignment: pw.CrossAxisAlignment.start, children: widgets); +} diff --git a/目標.md b/目標.md index 9490629..04af8cb 100644 --- a/目標.md +++ b/目標.md @@ -49,3 +49,11 @@ --- *最終更新日: 2026-02-14* *Version: 1.5.02* + +追加: +ファイル名ルール + {日付}({タイプ}){顧客名}_{案件}_{金額}_{HASH下8桁}.pdf + 20260214(請求書)佐々木製作所_10,000円_12345678.pdf +明細欄にはmarkdown的要素が使える様に、簡単なものが欲しい。 + インデントや箇条書き、太字など。問題はodooとの連携と型番。 +