feat: Introduce line items and customizable tax rate for invoice generation.

This commit is contained in:
joe 2026-02-14 19:52:50 +09:00
parent 191711803b
commit 70c902885a
14 changed files with 764 additions and 252 deletions

View file

@ -43,6 +43,7 @@ class Invoice {
final List<InvoiceItem> items; final List<InvoiceItem> items;
final String? notes; final String? notes;
final String? filePath; final String? filePath;
final double taxRate; //
final String? odooId; final String? odooId;
final bool isSynced; final bool isSynced;
final DateTime updatedAt; final DateTime updatedAt;
@ -54,6 +55,7 @@ class Invoice {
required this.items, required this.items,
this.notes, this.notes,
this.filePath, this.filePath,
this.taxRate = 0.10, // 10%
this.odooId, this.odooId,
this.isSynced = false, this.isSynced = false,
DateTime? updatedAt, DateTime? updatedAt,
@ -63,7 +65,7 @@ class Invoice {
String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}"; String get invoiceNumber => "INV-${DateFormat('yyyyMMdd').format(date)}-${id.substring(id.length > 4 ? id.length - 4 : 0)}";
int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal); int get subtotal => items.fold(0, (sum, item) => sum + item.subtotal);
int get tax => (subtotal * 0.1).floor(); int get tax => (subtotal * taxRate).floor(); // taxRateを使用
int get totalAmount => subtotal + tax; int get totalAmount => subtotal + tax;
Map<String, dynamic> toMap() { Map<String, dynamic> toMap() {
@ -74,6 +76,7 @@ class Invoice {
'notes': notes, 'notes': notes,
'file_path': filePath, 'file_path': filePath,
'total_amount': totalAmount, 'total_amount': totalAmount,
'tax_rate': taxRate, //
'odoo_id': odooId, 'odoo_id': odooId,
'is_synced': isSynced ? 1 : 0, 'is_synced': isSynced ? 1 : 0,
'updated_at': updatedAt.toIso8601String(), 'updated_at': updatedAt.toIso8601String(),
@ -106,6 +109,7 @@ class Invoice {
List<InvoiceItem>? items, List<InvoiceItem>? items,
String? notes, String? notes,
String? filePath, String? filePath,
double? taxRate,
String? odooId, String? odooId,
bool? isSynced, bool? isSynced,
DateTime? updatedAt, DateTime? updatedAt,
@ -117,6 +121,7 @@ class Invoice {
items: items ?? List.from(this.items), items: items ?? List.from(this.items),
notes: notes ?? this.notes, notes: notes ?? this.notes,
filePath: filePath ?? this.filePath, filePath: filePath ?? this.filePath,
taxRate: taxRate ?? this.taxRate,
odooId: odooId ?? this.odooId, odooId: odooId ?? this.odooId,
isSynced: isSynced ?? this.isSynced, isSynced: isSynced ?? this.isSynced,
updatedAt: updatedAt ?? this.updatedAt, updatedAt: updatedAt ?? this.updatedAt,

View file

@ -0,0 +1,45 @@
class Product {
final String id;
final String name;
final int defaultUnitPrice;
final String? odooId;
Product({
required this.id,
required this.name,
this.defaultUnitPrice = 0,
this.odooId,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'default_unit_price': defaultUnitPrice,
'odoo_id': odooId,
};
}
factory Product.fromMap(Map<String, dynamic> map) {
return Product(
id: map['id'],
name: map['name'],
defaultUnitPrice: map['default_unit_price'] ?? 0,
odooId: map['odoo_id'],
);
}
Product copyWith({
String? id,
String? name,
int? defaultUnitPrice,
String? odooId,
}) {
return Product(
id: id ?? this.id,
name: name ?? this.name,
defaultUnitPrice: defaultUnitPrice ?? this.defaultUnitPrice,
odooId: odooId ?? this.odooId,
);
}
}

View file

@ -276,16 +276,21 @@ class _InvoiceDetailPageState extends State<InvoiceDetailPage> {
} }
Widget _buildSummarySection(NumberFormat formatter) { Widget _buildSummarySection(NumberFormat formatter) {
final double currentTaxRate = _isEditing ? _currentInvoice.taxRate : _currentInvoice.taxRate; //
final int subtotal = _isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal;
final int tax = (subtotal * currentTaxRate).floor();
final int total = subtotal + tax;
return Align( return Align(
alignment: Alignment.centerRight, alignment: Alignment.centerRight,
child: Container( child: Container(
width: 200, width: 200,
child: Column( child: Column(
children: [ children: [
_SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)), _SummaryRow("小計 (税抜)", formatter.format(subtotal)),
_SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)), _SummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", formatter.format(tax)),
const Divider(), const Divider(),
_SummaryRow("合計 (税込)", "${formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 1.1).floor() : _currentInvoice.totalAmount)}", isBold: true), _SummaryRow("合計 (税込)", "${formatter.format(total)}", isBold: true),
], ],
), ),
), ),

View file

@ -6,6 +6,7 @@ import '../services/invoice_repository.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import 'invoice_detail_page.dart'; import 'invoice_detail_page.dart';
import 'management_screen.dart'; import 'management_screen.dart';
import '../widgets/slide_to_unlock.dart';
import '../main.dart'; // InvoiceFlowScreen import '../main.dart'; // InvoiceFlowScreen
class InvoiceHistoryScreen extends StatefulWidget { class InvoiceHistoryScreen extends StatefulWidget {
@ -73,9 +74,11 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
setState(() { setState(() {
_isUnlocked = !_isUnlocked; _isUnlocked = !_isUnlocked;
}); });
ScaffoldMessenger.of(context).showSnackBar( if (!_isUnlocked) {
SnackBar(content: Text(_isUnlocked ? "編集プロテクトを解除しました" : "編集プロテクトを有効にしました")), ScaffoldMessenger.of(context).showSnackBar(
); const SnackBar(content: Text("編集プロテクトを有効にしました")),
);
}
} }
@override @override
@ -88,11 +91,12 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
title: const Text("伝票マスター一覧"), title: const Text("伝票マスター一覧"),
backgroundColor: _isUnlocked ? Colors.blueGrey : Colors.blueGrey.shade800, backgroundColor: _isUnlocked ? Colors.blueGrey : Colors.blueGrey.shade800,
actions: [ actions: [
IconButton( if (_isUnlocked)
icon: Icon(_isUnlocked ? Icons.lock_open : Icons.lock, color: _isUnlocked ? Colors.orangeAccent : Colors.white70), IconButton(
onPressed: _toggleUnlock, icon: const Icon(Icons.lock_open, color: Colors.orangeAccent),
tooltip: _isUnlocked ? "プロテクトする" : "アンロックする", onPressed: _toggleUnlock,
), tooltip: "再度プロテクトする",
),
IconButton( IconButton(
icon: const Icon(Icons.sort), icon: const Icon(Icons.sort),
onPressed: () { onPressed: () {
@ -206,86 +210,97 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
], ],
), ),
), ),
body: _isLoading body: Column(
? const Center(child: CircularProgressIndicator()) children: [
: _filteredInvoices.isEmpty SlideToUnlock(
? Center( isLocked: !_isUnlocked,
child: Column( onUnlocked: _toggleUnlock,
mainAxisAlignment: MainAxisAlignment.center, text: "スライドして編集モード解除",
children: [ ),
const Icon(Icons.folder_open, size: 64, color: Colors.grey), Expanded(
const SizedBox(height: 16), child: _isLoading
Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"), ? const Center(child: CircularProgressIndicator())
], : _filteredInvoices.isEmpty
), ? Center(
) child: Column(
: ListView.builder( mainAxisAlignment: MainAxisAlignment.center,
itemCount: _filteredInvoices.length, children: [
itemBuilder: (context, index) { const Icon(Icons.folder_open, size: 64, color: Colors.grey),
final invoice = _filteredInvoices[index]; const SizedBox(height: 16),
return ListTile( Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"),
leading: CircleAvatar( ],
backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200, ),
child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey), )
), : ListView.builder(
title: Text(invoice.customer.formalName), itemCount: _filteredInvoices.length,
subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"), itemBuilder: (context, index) {
trailing: Column( final invoice = _filteredInvoices[index];
mainAxisAlignment: MainAxisAlignment.center, return ListTile(
crossAxisAlignment: CrossAxisAlignment.end, leading: CircleAvatar(
children: [ backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200,
Text("${amountFormatter.format(invoice.totalAmount)}", child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey),
style: const TextStyle(fontWeight: FontWeight.bold)), ),
if (invoice.isSynced) title: Text(invoice.customer.formalName),
const Icon(Icons.sync, size: 16, color: Colors.green) subtitle: Text("${dateFormatter.format(invoice.date)} - ${invoice.invoiceNumber}"),
else trailing: Column(
const Icon(Icons.sync_disabled, size: 16, color: Colors.orange), mainAxisAlignment: MainAxisAlignment.center,
], crossAxisAlignment: CrossAxisAlignment.end,
), children: [
onTap: () async { Text("${amountFormatter.format(invoice.totalAmount)}",
if (!_isUnlocked) { style: const TextStyle(fontWeight: FontWeight.bold)),
ScaffoldMessenger.of(context).showSnackBar( if (invoice.isSynced)
const SnackBar(content: Text("詳細の閲覧・編集にはアンロックが必要です"), duration: Duration(seconds: 1)), const Icon(Icons.sync, size: 16, color: Colors.green)
else
const Icon(Icons.sync_disabled, size: 16, color: Colors.orange),
],
),
onTap: () async {
if (!_isUnlocked) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("詳細の閲覧・編集にはアンロックが必要です"), duration: Duration(seconds: 1)),
);
return;
}
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => InvoiceDetailPage(invoice: invoice),
),
);
_loadData(); //
},
onLongPress: () async {
if (!_isUnlocked) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("削除するにはアンロックが必要です")),
);
return;
}
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("伝票の削除"),
content: Text("${invoice.customer.formalName}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("削除", style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirm == true) {
await _invoiceRepo.deleteInvoice(invoice.id);
_loadData();
}
},
); );
return; },
} ),
await Navigator.push( ),
context, ],
MaterialPageRoute( ),
builder: (context) => InvoiceDetailPage(invoice: invoice),
),
);
_loadData(); //
},
onLongPress: () async {
if (!_isUnlocked) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("削除するにはアンロックが必要です")),
);
return;
}
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("伝票の削除"),
content: Text("${invoice.customer.formalName}」の伝票(${invoice.invoiceNumber})を削除しますか?\nこの操作は取り消せません。"),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("削除", style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirm == true) {
await _invoiceRepo.deleteInvoice(invoice.id);
_loadData();
}
},
);
},
),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
onPressed: () async { onPressed: () async {
await Navigator.push( await Navigator.push(

View file

@ -1,13 +1,14 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import 'package:intl/intl.dart';
import '../models/customer_model.dart'; import '../models/customer_model.dart';
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../services/pdf_generator.dart'; import '../services/pdf_generator.dart';
import '../services/invoice_repository.dart'; import '../services/invoice_repository.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import 'customer_picker_modal.dart'; import 'customer_picker_modal.dart';
import 'product_picker_modal.dart';
///
class InvoiceInputForm extends StatefulWidget { class InvoiceInputForm extends StatefulWidget {
final Function(Invoice invoice, String filePath) onInvoiceGenerated; final Function(Invoice invoice, String filePath) onInvoiceGenerated;
@ -21,12 +22,15 @@ class InvoiceInputForm extends StatefulWidget {
} }
class _InvoiceInputFormState extends State<InvoiceInputForm> { class _InvoiceInputFormState extends State<InvoiceInputForm> {
final _clientController = TextEditingController();
final _amountController = TextEditingController(text: "250000");
final _repository = InvoiceRepository(); final _repository = InvoiceRepository();
String _status = "取引先を選択してPDFを生成してください";
Customer? _selectedCustomer; Customer? _selectedCustomer;
final List<InvoiceItem> _items = [];
double _taxRate = 0.10;
bool _includeTax = true;
String _status = "取引先と商品を入力してください";
//
List<Offset?> _signaturePath = [];
@override @override
void initState() { void initState() {
@ -35,176 +39,299 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
} }
Future<void> _loadInitialData() async { Future<void> _loadInitialData() async {
// PDFを掃除する _repository.cleanupOrphanedPdfs();
_repository.cleanupOrphanedPdfs().then((count) {
if (count > 0) {
debugPrint('Cleaned up $count orphaned PDF files.');
}
});
final customerRepo = CustomerRepository(); final customerRepo = CustomerRepository();
final customers = await customerRepo.getAllCustomers(); final customers = await customerRepo.getAllCustomers();
if (customers.isNotEmpty) { if (customers.isNotEmpty) {
setState(() { setState(() => _selectedCustomer = customers.first);
_selectedCustomer = customers.first;
_clientController.text = _selectedCustomer!.formalName;
});
} else {
//
final defaultCustomer = Customer(
id: const Uuid().v4(),
displayName: "佐々木製作所",
formalName: "株式会社 佐々木製作所",
);
await customerRepo.saveCustomer(defaultCustomer);
setState(() {
_selectedCustomer = defaultCustomer;
_clientController.text = _selectedCustomer!.formalName;
});
} }
} }
@override void _addItem() {
void dispose() { showModalBottomSheet(
_clientController.dispose();
_amountController.dispose();
super.dispose();
}
Future<void> _openCustomerPicker() async {
setState(() => _status = "顧客マスターを開いています...");
await showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, builder: (context) => ProductPickerModal(
builder: (context) => FractionallySizedBox( onItemSelected: (item) {
heightFactor: 0.9, setState(() => _items.add(item));
child: CustomerPickerModal( Navigator.pop(context);
onCustomerSelected: (customer) { },
setState(() {
_selectedCustomer = customer;
_clientController.text = customer.formalName;
_status = "${customer.formalName}」を選択しました";
});
Navigator.pop(context);
},
),
), ),
); );
} }
Future<void> _handleInitialGenerate() async { 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<void> _handleGenerate() async {
if (_selectedCustomer == null) { if (_selectedCustomer == null) {
setState(() => _status = "取引先を選択してください"); ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください")));
return;
}
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("明細を1件以上入力してください")));
return; return;
} }
final unitPrice = int.tryParse(_amountController.text) ?? 0;
final initialItems = [
InvoiceItem(
description: "ご請求分",
quantity: 1,
unitPrice: unitPrice,
)
];
final invoice = Invoice( final invoice = Invoice(
customer: _selectedCustomer!, customer: _selectedCustomer!,
date: DateTime.now(), date: DateTime.now(),
items: initialItems, items: _items,
taxRate: _includeTax ? _taxRate : 0.0, //
notes: _includeTax ? "(消費税 ${(_taxRate * 100).toInt()}% 込み)" : "(非課税)",
); );
setState(() => _status = "A4請求書を生成中..."); setState(() => _status = "PDFを生成中...");
final path = await generateInvoicePdf(invoice); final path = await generateInvoicePdf(invoice);
if (path != null) { if (path != null) {
final updatedInvoice = invoice.copyWith(filePath: path); final updatedInvoice = invoice.copyWith(filePath: path);
// DBに保存
await _repository.saveInvoice(updatedInvoice); await _repository.saveInvoice(updatedInvoice);
widget.onInvoiceGenerated(updatedInvoice, path); widget.onInvoiceGenerated(updatedInvoice, path);
setState(() => _status = "PDFを生成しDBに登録しました。");
} else {
setState(() => _status = "PDFの生成に失敗しました");
} }
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( final fmt = NumberFormat("#,###");
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView( return Column(
child: Column(children: [ children: [
const Text( Expanded(
"ステップ1: 宛先と基本金額の設定", child: SingleChildScrollView(
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey), padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildCustomerSection(),
const SizedBox(height: 20),
_buildItemsSection(fmt),
const SizedBox(height: 20),
_buildExperimentalSection(),
const SizedBox(height: 20),
_buildSummarySection(fmt),
const SizedBox(height: 20),
_buildSignatureSection(),
],
),
), ),
const SizedBox(height: 16), ),
Row(children: [ _buildBottomActionBar(),
Expanded( ],
child: TextField( );
controller: _clientController, }
readOnly: true,
onTap: _openCustomerPicker, Widget _buildCustomerSection() {
decoration: const InputDecoration( return Card(
labelText: "取引先名 (タップして選択)", elevation: 0,
hintText: "電話帳から取り込むか、マスターから選択", color: Colors.blueGrey.shade50,
prefixIcon: Icon(Icons.business), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
border: OutlineInputBorder(), 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)),
),
],
), ),
), ),
), );
const SizedBox(width: 8), }),
IconButton( ],
icon: const Icon(Icons.person_add_alt_1, color: Colors.indigo, size: 40), );
onPressed: _openCustomerPicker, }
tooltip: "顧客を選択・登録",
), Widget _buildExperimentalSection() {
]), return Container(
const SizedBox(height: 16), padding: const EdgeInsets.all(12),
TextField( decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(12)),
controller: _amountController, child: Column(
keyboardType: TextInputType.number, crossAxisAlignment: CrossAxisAlignment.start,
decoration: const InputDecoration( children: [
labelText: "基本金額 (税抜)", const Text("実験的オプション", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)),
hintText: "明細の1行目として登録されます", const SizedBox(height: 8),
prefixIcon: Icon(Icons.currency_yen), Row(
border: OutlineInputBorder(), 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),
_buildSummaryRow("消費税", "${fmt.format(_tax)}", 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,
), ),
), ),
const SizedBox(height: 24), ),
ElevatedButton.icon( ],
onPressed: _handleInitialGenerate, );
icon: const Icon(Icons.description), }
label: const Text("A4請求書を作成して詳細編集へ"),
style: ElevatedButton.styleFrom( Widget _buildBottomActionBar() {
minimumSize: const Size(double.infinity, 60), return Container(
backgroundColor: Colors.indigo, padding: const EdgeInsets.all(16),
foregroundColor: Colors.white, decoration: BoxDecoration(
elevation: 4, color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))],
), ),
), child: ElevatedButton.icon(
const SizedBox(height: 24), onPressed: _handleGenerate,
Container( icon: const Icon(Icons.picture_as_pdf),
width: double.infinity, label: const Text("伝票を確定してPDF生成"),
padding: const EdgeInsets.all(12), style: ElevatedButton.styleFrom(
decoration: BoxDecoration( minimumSize: const Size(double.infinity, 60),
color: Colors.grey[100], backgroundColor: Colors.indigo,
borderRadius: BorderRadius.circular(8), foregroundColor: Colors.white,
border: Border.all(color: Colors.grey.shade300), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
), ),
child: Text(
_status,
style: const TextStyle(fontSize: 12, color: Colors.black54),
textAlign: TextAlign.center,
),
),
]),
), ),
); );
} }
} }
class SignaturePainter extends CustomPainter {
final List<Offset?> 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;
}

View file

@ -6,6 +6,7 @@ import 'package:path/path.dart' as p;
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../services/invoice_repository.dart'; import '../services/invoice_repository.dart';
import '../services/customer_repository.dart'; import '../services/customer_repository.dart';
import 'product_master_screen.dart';
class ManagementScreen extends StatelessWidget { class ManagementScreen extends StatelessWidget {
const ManagementScreen({Key? key}) : super(key: key); const ManagementScreen({Key? key}) : super(key: key);
@ -20,6 +21,13 @@ class ManagementScreen extends StatelessWidget {
body: ListView( body: ListView(
children: [ children: [
_buildSectionHeader("データ入出力"), _buildSectionHeader("データ入出力"),
_buildMenuTile(
context,
Icons.inventory_2,
"商品マスター管理",
"販売商品の名称や単価を管理します",
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())),
),
_buildMenuTile( _buildMenuTile(
context, context,
Icons.upload_file, Icons.upload_file,

View file

@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import '../models/product_model.dart';
import '../services/product_repository.dart';
class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({Key? key}) : super(key: key);
@override
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
}
class _ProductMasterScreenState extends State<ProductMasterScreen> {
final ProductRepository _productRepo = ProductRepository();
List<Product> _products = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadProducts();
}
Future<void> _loadProducts() async {
setState(() => _isLoading = true);
final products = await _productRepo.getAllProducts();
setState(() {
_products = products;
_isLoading = false;
});
}
Future<void> _addItem({Product? product}) async {
final isEdit = product != null;
final nameController = TextEditingController(text: product?.name ?? "");
final priceController = TextEditingController(text: product?.defaultUnitPrice.toString() ?? "0");
final result = await showDialog<Product>(
context: context,
builder: (context) => AlertDialog(
title: Text(isEdit ? "商品を編集" : "商品を新規登録"),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: nameController,
decoration: const InputDecoration(labelText: "商品名"),
),
TextField(
controller: priceController,
decoration: const InputDecoration(labelText: "初期単価"),
keyboardType: TextInputType.number,
),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
TextButton(
onPressed: () {
if (nameController.text.isEmpty) return;
final newProduct = Product(
id: product?.id ?? const Uuid().v4(),
name: nameController.text,
defaultUnitPrice: int.tryParse(priceController.text) ?? 0,
odooId: product?.odooId,
);
Navigator.pop(context, newProduct);
},
child: const Text("保存"),
),
],
),
);
if (result != null) {
await _productRepo.saveProduct(result);
_loadProducts();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("商品マスター管理"),
backgroundColor: Colors.blueGrey,
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _products.isEmpty
? const Center(child: Text("商品が登録されていません"))
: ListView.builder(
itemCount: _products.length,
itemBuilder: (context, index) {
final p = _products[index];
return ListTile(
title: Text(p.name),
subtitle: Text("初期単価: ¥${p.defaultUnitPrice}"),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _addItem(product: p)),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("削除確認"),
content: Text("${p.name}」を削除しますか?"),
actions: [
TextButton(onPressed: () => Navigator.pop(context, false), child: const Text("キャンセル")),
TextButton(onPressed: () => Navigator.pop(context, true), child: const Text("削除", style: TextStyle(color: Colors.red))),
],
),
);
if (confirm == true) {
await _productRepo.deleteProduct(p.id);
_loadProducts();
}
},
),
],
),
);
},
),
floatingActionButton: FloatingActionButton(
onPressed: _addItem,
child: const Icon(Icons.add),
backgroundColor: Colors.indigo,
),
);
}
}

View file

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../models/invoice_models.dart'; import '../models/invoice_models.dart';
import '../models/product_model.dart';
import '../services/product_repository.dart';
import 'product_master_screen.dart';
/// ///
class ProductPickerModal extends StatefulWidget { class ProductPickerModal extends StatefulWidget {
@ -12,14 +15,24 @@ class ProductPickerModal extends StatefulWidget {
} }
class _ProductPickerModalState extends State<ProductPickerModal> { class _ProductPickerModalState extends State<ProductPickerModal> {
// final ProductRepository _productRepo = ProductRepository();
final List<InvoiceItem> _masterProducts = [ List<Product> _products = [];
InvoiceItem(description: "技術料", quantity: 1, unitPrice: 50000), bool _isLoading = true;
InvoiceItem(description: "部品代 A", quantity: 1, unitPrice: 15000),
InvoiceItem(description: "部品代 B", quantity: 1, unitPrice: 3000), @override
InvoiceItem(description: "出張費", quantity: 1, unitPrice: 10000), void initState() {
InvoiceItem(description: "諸経費", quantity: 1, unitPrice: 5000), super.initState();
]; _loadProducts();
}
Future<void> _loadProducts() async {
setState(() => _isLoading = true);
final products = await _productRepo.getAllProducts();
setState(() {
_products = products;
_isLoading = false;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -42,19 +55,55 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
), ),
const Divider(), const Divider(),
Expanded( Expanded(
child: ListView.builder( child: _isLoading
itemCount: _masterProducts.length, ? const Center(child: CircularProgressIndicator())
itemBuilder: (context, index) { : _products.isEmpty
final product = _masterProducts[index]; ? Center(
return ListTile( child: Column(
leading: const Icon(Icons.inventory_2_outlined), mainAxisAlignment: MainAxisAlignment.center,
title: Text(product.description), children: [
subtitle: Text("単価: ¥${product.unitPrice}"), const Text("商品マスターが空です"),
onTap: () => widget.onItemSelected(product), TextButton(
); onPressed: () async {
}, await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
), _loadProducts();
},
child: const Text("商品マスターを編集する"),
),
],
),
)
: ListView.builder(
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: Text(product.name),
subtitle: Text("初期単価: ¥${product.defaultUnitPrice}"),
onTap: () => widget.onItemSelected(
InvoiceItem(
description: product.name,
quantity: 1,
unitPrice: product.defaultUnitPrice,
),
),
);
},
),
), ),
if (_products.isNotEmpty)
Padding(
padding: const EdgeInsets.all(8.0),
child: TextButton.icon(
icon: const Icon(Icons.edit),
label: const Text("商品マスターの管理"),
onPressed: () async {
await Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen()));
_loadProducts();
},
),
),
], ],
), ),
); );

View file

@ -19,11 +19,18 @@ class DatabaseHelper {
String path = join(await getDatabasesPath(), 'gemi_invoice.db'); String path = join(await getDatabasesPath(), 'gemi_invoice.db');
return await openDatabase( return await openDatabase(
path, path,
version: 1, version: 2,
onCreate: _onCreate, onCreate: _onCreate,
onUpgrade: _onUpgrade,
); );
} }
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
if (oldVersion < 2) {
await db.execute('ALTER TABLE invoices ADD COLUMN tax_rate REAL DEFAULT 0.10');
}
}
Future<void> _onCreate(Database db, int version) async { Future<void> _onCreate(Database db, int version) async {
// //
await db.execute(''' await db.execute('''
@ -72,6 +79,7 @@ class DatabaseHelper {
notes TEXT, notes TEXT,
file_path TEXT, file_path TEXT,
total_amount INTEGER, total_amount INTEGER,
tax_rate REAL DEFAULT 0.10,
odoo_id TEXT, odoo_id TEXT,
is_synced INTEGER DEFAULT 0, is_synced INTEGER DEFAULT 0,
updated_at TEXT NOT NULL, updated_at TEXT NOT NULL,

View file

@ -59,6 +59,7 @@ class InvoiceRepository {
items: items, items: items,
notes: iMap['notes'], notes: iMap['notes'],
filePath: iMap['file_path'], filePath: iMap['file_path'],
taxRate: iMap['tax_rate'] ?? 0.10, //
odooId: iMap['odoo_id'], odooId: iMap['odoo_id'],
isSynced: iMap['is_synced'] == 1, isSynced: iMap['is_synced'] == 1,
updatedAt: DateTime.parse(iMap['updated_at']), updatedAt: DateTime.parse(iMap['updated_at']),

View file

@ -133,7 +133,7 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
children: [ children: [
pw.SizedBox(height: 10), pw.SizedBox(height: 10),
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)), _buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
_buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)), _buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
pw.Divider(), pw.Divider(),
_buildSummaryRow("合計", "${amountFormatter.format(invoice.totalAmount)}", isBold: true), _buildSummaryRow("合計", "${amountFormatter.format(invoice.totalAmount)}", isBold: true),
], ],

View file

@ -0,0 +1,27 @@
import 'package:sqflite/sqflite.dart';
import '../models/product_model.dart';
import 'database_helper.dart';
class ProductRepository {
final DatabaseHelper _dbHelper = DatabaseHelper();
Future<List<Product>> getAllProducts() async {
final db = await _dbHelper.database;
final List<Map<String, dynamic>> maps = await db.query('products', orderBy: 'name ASC');
return List.generate(maps.length, (i) => Product.fromMap(maps[i]));
}
Future<void> saveProduct(Product product) async {
final db = await _dbHelper.database;
await db.insert(
'products',
product.toMap(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
Future<void> deleteProduct(String id) async {
final db = await _dbHelper.database;
await db.delete('products', where: 'id = ?', whereArgs: [id]);
}
}

View file

@ -0,0 +1,85 @@
import 'package:flutter/material.dart';
class SlideToUnlock extends StatefulWidget {
final VoidCallback onUnlocked;
final String text;
final bool isLocked;
const SlideToUnlock({
Key? key,
required this.onUnlocked,
this.text = "スライドして解除",
this.isLocked = true,
}) : super(key: key);
@override
State<SlideToUnlock> createState() => _SlideToUnlockState();
}
class _SlideToUnlockState extends State<SlideToUnlock> {
double _position = 0.0;
final double _thumbSize = 50.0;
@override
Widget build(BuildContext context) {
if (!widget.isLocked) return const SizedBox.shrink();
return LayoutBuilder(
builder: (context, constraints) {
final double maxWidth = constraints.maxWidth;
final double trackWidth = maxWidth - _thumbSize;
return Container(
height: 60,
margin: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.blueGrey.shade100,
borderRadius: BorderRadius.circular(30),
),
child: Stack(
children: [
Center(
child: Text(
widget.text,
style: TextStyle(color: Colors.blueGrey.shade700, fontWeight: FontWeight.bold),
),
),
Positioned(
left: _position,
child: GestureDetector(
onHorizontalDragUpdate: (details) {
setState(() {
_position += details.delta.dx;
if (_position < 0) _position = 0;
if (_position > trackWidth) _position = trackWidth;
});
},
onHorizontalDragEnd: (details) {
if (_position >= trackWidth * 0.9) {
widget.onUnlocked();
setState(() => _position = 0); //
} else {
setState(() => _position = 0);
}
},
child: Container(
width: _thumbSize,
height: 60,
decoration: BoxDecoration(
color: Colors.orangeAccent,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.2), blurRadius: 4, offset: const Offset(2, 2)),
],
),
child: const Icon(Icons.arrow_forward_ios, color: Colors.white),
),
),
),
],
),
);
},
);
}
}

View file

@ -22,4 +22,6 @@
- 商品マスター管理画面の実装 - 商品マスター管理画面の実装
- 伝票入力画面の実装 - 伝票入力画面の実装
- 伝票入力はあれもこれも盛り込みたいので実験的に色んなのを実装 - 伝票入力はあれもこれも盛り込みたいので実験的に色んなのを実装
商品マスター編集画面の実装
- 顧客マスター編集画面の実装