feat: Add subject field to invoices, integrating it into the model, UI, and database.

This commit is contained in:
joe 2026-02-14 23:53:53 +09:00
parent 81c2fc38a8
commit 6643aa4157
8 changed files with 152 additions and 53 deletions

View file

@ -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,
);
}

View file

@ -328,6 +328,11 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
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) ...[

View file

@ -259,14 +259,26 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
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

View file

@ -35,6 +35,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
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<InvoiceInputForm> {
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<InvoiceInputForm> {
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<InvoiceInputForm> {
_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<InvoiceInputForm> {
}
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(
// ... (existing code omitted for brevity but I'll provide the new method below it)
}
Widget _buildSubjectSection(Color textColor) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
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),
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),
),
],
),
);
}
}

View file

@ -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<void> _onCreate(Database db, int version) async {

View file

@ -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;

View file

@ -134,29 +134,41 @@ Future<pw.Document> 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,
},
headers: ["品名 / 項目", "数量", "単価", "金額"],
data: List<List<String>>.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.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)),
],
);
}),
],
),
//
@ -239,8 +251,12 @@ Future<String?> 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<pw.Widget> 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<pw.TextSpan> 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);
}

View file

@ -49,3 +49,11 @@
---
*最終更新日: 2026-02-14*
*Version: 1.5.02*
追加:
ファイル名ルール
{日付}({タイプ}){顧客名}_{案件}_{金額}_{HASH下8桁}.pdf
20260214(請求書)佐々木製作所_10,000円_12345678.pdf
明細欄にはmarkdown的要素が使える様に、簡単なものが欲しい。
 インデントや箇条書き、太字など。問題はodooとの連携と型番。