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 double? longitude; //
final String terminalId; // : final String terminalId; // :
final bool isDraft; // : final bool isDraft; // :
final String? subject; // :
Invoice({ Invoice({
String? id, String? id,
@ -84,13 +85,14 @@ class Invoice {
this.longitude, // this.longitude, //
String? terminalId, // String? terminalId, //
this.isDraft = false, // : this.isDraft = false, // :
this.subject, // :
}) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(), }) : id = id ?? DateTime.now().millisecondsSinceEpoch.toString(),
terminalId = terminalId ?? "T1", // ID terminalId = terminalId ?? "T1", // ID
updatedAt = updatedAt ?? DateTime.now(); updatedAt = updatedAt ?? DateTime.now();
/// (SHA256の一部) /// (SHA256の一部)
String get contentHash { 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); final bytes = utf8.encode(input);
return sha256.convert(bytes).toString().substring(0, 8).toUpperCase(); return sha256.convert(bytes).toString().substring(0, 8).toUpperCase();
} }
@ -141,6 +143,7 @@ class Invoice {
'terminal_id': terminalId, // 'terminal_id': terminalId, //
'content_hash': contentHash, // 'content_hash': contentHash, //
'is_draft': isDraft ? 1 : 0, // 'is_draft': isDraft ? 1 : 0, //
'subject': subject, //
}; };
} }
@ -161,6 +164,7 @@ class Invoice {
double? longitude, double? longitude,
String? terminalId, String? terminalId,
bool? isDraft, bool? isDraft,
String? subject,
}) { }) {
return Invoice( return Invoice(
id: id ?? this.id, id: id ?? this.id,
@ -179,6 +183,7 @@ class Invoice {
longitude: longitude ?? this.longitude, longitude: longitude ?? this.longitude,
terminalId: terminalId ?? this.terminalId, terminalId: terminalId ?? this.terminalId,
isDraft: isDraft ?? this.isDraft, 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("取引先:", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}", Text("${_currentInvoice.customerNameForDisplay} ${_currentInvoice.customer.title}",
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: textColor)), 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) if (_currentInvoice.customer.department != null && _currentInvoice.customer.department!.isNotEmpty)
Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)), Text(_currentInvoice.customer.department!, style: TextStyle(fontSize: 16, color: textColor)),
if (_currentInvoice.latitude != null) ...[ if (_currentInvoice.latitude != null) ...[

View file

@ -259,14 +259,26 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200, backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200,
child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey), 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}"), subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
trailing: Column( trailing: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text("${amountFormatter.format(invoice.totalAmount)}", Text("${amountFormatter.format(invoice.totalAmount)}",
style: const TextStyle(fontWeight: FontWeight.bold)), style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
if (invoice.isSynced) if (invoice.isSynced)
const Icon(Icons.sync, size: 16, color: Colors.green) const Icon(Icons.sync, size: 16, color: Colors.green)
else else

View file

@ -35,6 +35,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
DocumentType _documentType = DocumentType.invoice; // DocumentType _documentType = DocumentType.invoice; //
DateTime _selectedDate = DateTime.now(); // : DateTime _selectedDate = DateTime.now(); // :
bool _isDraft = false; // : bool _isDraft = false; // :
final TextEditingController _subjectController = TextEditingController(); //
String _status = "取引先と商品を入力してください"; String _status = "取引先と商品を入力してください";
// //
@ -98,14 +99,16 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
final invoice = Invoice( final invoice = Invoice(
customer: _selectedCustomer!, customer: _selectedCustomer!,
date: _selectedDate, // date: _selectedDate,
items: _items, items: _items,
taxRate: _includeTax ? _taxRate : 0.0, taxRate: _includeTax ? _taxRate : 0.0,
documentType: _documentType, documentType: _documentType,
customerFormalNameSnapshot: _selectedCustomer!.formalName, customerFormalNameSnapshot: _selectedCustomer!.formalName,
subject: _subjectController.text.isNotEmpty ? _subjectController.text : null, //
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)", notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
latitude: pos?.latitude, latitude: pos?.latitude,
longitude: pos?.longitude, longitude: pos?.longitude,
isDraft: _isDraft, //
); );
if (generatePdf) { if (generatePdf) {
@ -177,7 +180,7 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
backgroundColor: themeColor, backgroundColor: themeColor,
appBar: AppBar( appBar: AppBar(
leading: const BackButton(), leading: const BackButton(),
title: Text(_isDraft ? "伝票作成 (下書きモード)" : "販売アシスト1号 V1.5.02"), title: Text(_isDraft ? "伝票作成 (下書きモード)" : "販売アシスト1号 V1.5.03"),
backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey, backgroundColor: _isDraft ? Colors.black87 : Colors.blueGrey,
), ),
body: Column( body: Column(
@ -195,6 +198,8 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
_buildDateSection(), _buildDateSection(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildCustomerSection(), _buildCustomerSection(),
const SizedBox(height: 16),
_buildSubjectSection(textColor), //
const SizedBox(height: 20), const SizedBox(height: 20),
_buildItemsSection(fmt), _buildItemsSection(fmt),
const SizedBox(height: 20), const SizedBox(height: 20),
@ -516,30 +521,28 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
} }
Widget _buildDraftToggle() { Widget _buildDraftToggle() {
return Container( // ... (existing code omitted for brevity but I'll provide the new method below it)
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), }
decoration: BoxDecoration(
color: _isDraft ? Colors.black26 : Colors.orange.shade50, Widget _buildSubjectSection(Color textColor) {
borderRadius: BorderRadius.circular(12), return Column(
border: Border.all(color: _isDraft ? Colors.orangeAccent : Colors.orange, width: 2), crossAxisAlignment: CrossAxisAlignment.start,
), children: [
child: Row( Text("案件名 / 件名", style: TextStyle(fontWeight: FontWeight.bold, color: textColor)),
children: [ const SizedBox(height: 8),
Icon(_isDraft ? Icons.drafts : Icons.check_circle, color: Colors.orange), TextField(
const SizedBox(width: 12), controller: _subjectController,
Expanded( style: TextStyle(color: textColor),
child: Text( decoration: InputDecoration(
_isDraft ? "下書きモード設定中" : "正式発行モード", hintText: "例:事務所改修工事 / 〇〇月分リース料",
style: TextStyle(fontWeight: FontWeight.bold, color: _isDraft ? Colors.white : Colors.orange.shade900), 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'; import 'package:path/path.dart';
class DatabaseHelper { class DatabaseHelper {
static const _databaseVersion = 11; static const _databaseVersion = 12;
static final DatabaseHelper _instance = DatabaseHelper._internal(); static final DatabaseHelper _instance = DatabaseHelper._internal();
static Database? _database; static Database? _database;
@ -91,6 +91,9 @@ class DatabaseHelper {
if (oldVersion < 11) { if (oldVersion < 11) {
await db.execute('ALTER TABLE invoices ADD COLUMN is_draft INTEGER DEFAULT 0'); 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 { Future<void> _onCreate(Database db, int version) async {

View file

@ -113,6 +113,7 @@ class InvoiceRepository {
longitude: iMap['longitude'], longitude: iMap['longitude'],
terminalId: iMap['terminal_id'] ?? "T1", terminalId: iMap['terminal_id'] ?? "T1",
isDraft: (iMap['is_draft'] ?? 0) == 1, isDraft: (iMap['is_draft'] ?? 0) == 1,
subject: iMap['subject'],
)); ));
} }
return invoices; return invoices;

View file

@ -134,29 +134,41 @@ Future<pw.Document> buildInvoiceDocument(Invoice invoice) async {
pw.SizedBox(height: 20), pw.SizedBox(height: 20),
// //
pw.TableHelper.fromTextArray( //
headerStyle: pw.TextStyle(fontWeight: pw.FontWeight.bold), pw.Table(
headerDecoration: const pw.BoxDecoration(color: PdfColors.grey300), border: pw.TableBorder.all(color: PdfColors.grey300),
cellHeight: 30, columnWidths: {
cellAlignments: { 0: const pw.FlexColumnWidth(4),
0: pw.Alignment.centerLeft, 1: const pw.FixedColumnWidth(50),
1: pw.Alignment.centerRight, 2: const pw.FixedColumnWidth(80),
2: pw.Alignment.centerRight, 3: const pw.FixedColumnWidth(80),
3: pw.Alignment.centerRight,
}, },
headers: ["品名 / 項目", "数量", "単価", "金額"], children: [
data: List<List<String>>.generate( //
invoice.items.length, pw.TableRow(
(index) { decoration: const pw.BoxDecoration(color: PdfColors.grey300),
final item = invoice.items[index]; children: [
return [ pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("品名 / 項目", style: pw.TextStyle(fontWeight: pw.FontWeight.bold))),
item.description, pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("数量", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
item.quantity.toString(), pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("単価", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
amountFormatter.format(item.unitPrice), pw.Padding(padding: const pw.EdgeInsets.all(4), child: pw.Text("金額", style: pw.TextStyle(fontWeight: pw.FontWeight.bold), textAlign: pw.TextAlign.right)),
amountFormatter.format(item.subtotal), ],
]; ),
}, //
), ...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 pdf = await buildInvoiceDocument(invoice);
final String hash = invoice.contentHash; final String hash = invoice.contentHash;
final String timeStr = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); final String dateStr = DateFormat('yyyyMMdd').format(invoice.date);
String fileName = "${invoice.invoiceNumberPrefix}_${invoice.terminalId}_${invoice.id.substring(invoice.id.length - 4)}_${timeStr}_$hash.pdf"; 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(); final directory = await getExternalStorageDirectory();
if (directory == null) return null; 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* *最終更新日: 2026-02-14*
*Version: 1.5.02* *Version: 1.5.02*
追加:
ファイル名ルール
{日付}({タイプ}){顧客名}_{案件}_{金額}_{HASH下8桁}.pdf
20260214(請求書)佐々木製作所_10,000円_12345678.pdf
明細欄にはmarkdown的要素が使える様に、簡単なものが欲しい。
 インデントや箇条書き、太字など。問題はodooとの連携と型番。