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 String? notes;
final String? filePath;
final double taxRate; //
final String? odooId;
final bool isSynced;
final DateTime updatedAt;
@ -54,6 +55,7 @@ class Invoice {
required this.items,
this.notes,
this.filePath,
this.taxRate = 0.10, // 10%
this.odooId,
this.isSynced = false,
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)}";
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;
Map<String, dynamic> toMap() {
@ -74,6 +76,7 @@ class Invoice {
'notes': notes,
'file_path': filePath,
'total_amount': totalAmount,
'tax_rate': taxRate, //
'odoo_id': odooId,
'is_synced': isSynced ? 1 : 0,
'updated_at': updatedAt.toIso8601String(),
@ -106,6 +109,7 @@ class Invoice {
List<InvoiceItem>? items,
String? notes,
String? filePath,
double? taxRate,
String? odooId,
bool? isSynced,
DateTime? updatedAt,
@ -117,6 +121,7 @@ class Invoice {
items: items ?? List.from(this.items),
notes: notes ?? this.notes,
filePath: filePath ?? this.filePath,
taxRate: taxRate ?? this.taxRate,
odooId: odooId ?? this.odooId,
isSynced: isSynced ?? this.isSynced,
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) {
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(
alignment: Alignment.centerRight,
child: Container(
width: 200,
child: Column(
children: [
_SummaryRow("小計 (税抜)", formatter.format(_isEditing ? _calculateCurrentSubtotal() : _currentInvoice.subtotal)),
_SummaryRow("消費税 (10%)", formatter.format(_isEditing ? (_calculateCurrentSubtotal() * 0.1).floor() : _currentInvoice.tax)),
_SummaryRow("小計 (税抜)", formatter.format(subtotal)),
_SummaryRow("消費税 (${(currentTaxRate * 100).toInt()}%)", formatter.format(tax)),
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 'invoice_detail_page.dart';
import 'management_screen.dart';
import '../widgets/slide_to_unlock.dart';
import '../main.dart'; // InvoiceFlowScreen
class InvoiceHistoryScreen extends StatefulWidget {
@ -73,9 +74,11 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
setState(() {
_isUnlocked = !_isUnlocked;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(_isUnlocked ? "編集プロテクトを解除しました" : "編集プロテクトを有効にしました")),
);
if (!_isUnlocked) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("編集プロテクトを有効にしました")),
);
}
}
@override
@ -88,11 +91,12 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
title: const Text("伝票マスター一覧"),
backgroundColor: _isUnlocked ? Colors.blueGrey : Colors.blueGrey.shade800,
actions: [
IconButton(
icon: Icon(_isUnlocked ? Icons.lock_open : Icons.lock, color: _isUnlocked ? Colors.orangeAccent : Colors.white70),
onPressed: _toggleUnlock,
tooltip: _isUnlocked ? "プロテクトする" : "アンロックする",
),
if (_isUnlocked)
IconButton(
icon: const Icon(Icons.lock_open, color: Colors.orangeAccent),
onPressed: _toggleUnlock,
tooltip: "再度プロテクトする",
),
IconButton(
icon: const Icon(Icons.sort),
onPressed: () {
@ -206,86 +210,97 @@ class _InvoiceHistoryScreenState extends State<InvoiceHistoryScreen> {
],
),
),
body: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredInvoices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"),
],
),
)
: ListView.builder(
itemCount: _filteredInvoices.length,
itemBuilder: (context, index) {
final invoice = _filteredInvoices[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200,
child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey),
),
title: Text(invoice.customer.formalName),
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)),
if (invoice.isSynced)
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)),
body: Column(
children: [
SlideToUnlock(
isLocked: !_isUnlocked,
onUnlocked: _toggleUnlock,
text: "スライドして編集モード解除",
),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredInvoices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.folder_open, size: 64, color: Colors.grey),
const SizedBox(height: 16),
Text(_searchQuery.isEmpty ? "保存された伝票がありません" : "該当する伝票が見つかりません"),
],
),
)
: ListView.builder(
itemCount: _filteredInvoices.length,
itemBuilder: (context, index) {
final invoice = _filteredInvoices[index];
return ListTile(
leading: CircleAvatar(
backgroundColor: _isUnlocked ? Colors.indigo.shade100 : Colors.grey.shade200,
child: Icon(Icons.description_outlined, color: _isUnlocked ? Colors.indigo : Colors.grey),
),
title: Text(invoice.customer.formalName),
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)),
if (invoice.isSynced)
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(
onPressed: () async {
await Navigator.push(

View file

@ -1,13 +1,14 @@
import 'package:flutter/material.dart';
import 'package:uuid/uuid.dart';
import 'package:intl/intl.dart';
import '../models/customer_model.dart';
import '../models/invoice_models.dart';
import '../services/pdf_generator.dart';
import '../services/invoice_repository.dart';
import '../services/customer_repository.dart';
import 'customer_picker_modal.dart';
import 'product_picker_modal.dart';
///
class InvoiceInputForm extends StatefulWidget {
final Function(Invoice invoice, String filePath) onInvoiceGenerated;
@ -21,12 +22,15 @@ class InvoiceInputForm extends StatefulWidget {
}
class _InvoiceInputFormState extends State<InvoiceInputForm> {
final _clientController = TextEditingController();
final _amountController = TextEditingController(text: "250000");
final _repository = InvoiceRepository();
String _status = "取引先を選択してPDFを生成してください";
Customer? _selectedCustomer;
final List<InvoiceItem> _items = [];
double _taxRate = 0.10;
bool _includeTax = true;
String _status = "取引先と商品を入力してください";
//
List<Offset?> _signaturePath = [];
@override
void initState() {
@ -35,176 +39,299 @@ class _InvoiceInputFormState extends State<InvoiceInputForm> {
}
Future<void> _loadInitialData() async {
// PDFを掃除する
_repository.cleanupOrphanedPdfs().then((count) {
if (count > 0) {
debugPrint('Cleaned up $count orphaned PDF files.');
}
});
_repository.cleanupOrphanedPdfs();
final customerRepo = CustomerRepository();
final customers = await customerRepo.getAllCustomers();
if (customers.isNotEmpty) {
setState(() {
_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;
});
setState(() => _selectedCustomer = customers.first);
}
}
@override
void dispose() {
_clientController.dispose();
_amountController.dispose();
super.dispose();
}
Future<void> _openCustomerPicker() async {
setState(() => _status = "顧客マスターを開いています...");
await showModalBottomSheet<void>(
void _addItem() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (context) => FractionallySizedBox(
heightFactor: 0.9,
child: CustomerPickerModal(
onCustomerSelected: (customer) {
setState(() {
_selectedCustomer = customer;
_clientController.text = customer.formalName;
_status = "${customer.formalName}」を選択しました";
});
Navigator.pop(context);
},
),
builder: (context) => ProductPickerModal(
onItemSelected: (item) {
setState(() => _items.add(item));
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) {
setState(() => _status = "取引先を選択してください");
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("取引先を選択してください")));
return;
}
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("明細を1件以上入力してください")));
return;
}
final unitPrice = int.tryParse(_amountController.text) ?? 0;
final initialItems = [
InvoiceItem(
description: "ご請求分",
quantity: 1,
unitPrice: unitPrice,
)
];
final invoice = Invoice(
customer: _selectedCustomer!,
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);
if (path != null) {
final updatedInvoice = invoice.copyWith(filePath: path);
// DBに保存
await _repository.saveInvoice(updatedInvoice);
widget.onInvoiceGenerated(updatedInvoice, path);
setState(() => _status = "PDFを生成しDBに登録しました。");
} else {
setState(() => _status = "PDFの生成に失敗しました");
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: SingleChildScrollView(
child: Column(children: [
const Text(
"ステップ1: 宛先と基本金額の設定",
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.blueGrey),
final fmt = NumberFormat("#,###");
return Column(
children: [
Expanded(
child: SingleChildScrollView(
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: [
Expanded(
child: TextField(
controller: _clientController,
readOnly: true,
onTap: _openCustomerPicker,
decoration: const InputDecoration(
labelText: "取引先名 (タップして選択)",
hintText: "電話帳から取り込むか、マスターから選択",
prefixIcon: Icon(Icons.business),
border: OutlineInputBorder(),
),
_buildBottomActionBar(),
],
);
}
Widget _buildCustomerSection() {
return Card(
elevation: 0,
color: Colors.blueGrey.shade50,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
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: "顧客を選択・登録",
),
]),
const SizedBox(height: 16),
TextField(
controller: _amountController,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: "基本金額 (税抜)",
hintText: "明細の1行目として登録されます",
prefixIcon: Icon(Icons.currency_yen),
border: OutlineInputBorder(),
);
}),
],
);
}
Widget _buildExperimentalSection() {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.orange.shade50, borderRadius: BorderRadius.circular(12)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("実験的オプション", style: TextStyle(fontWeight: FontWeight.bold, color: Colors.orange)),
const SizedBox(height: 8),
Row(
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(
minimumSize: const Size(double.infinity, 60),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
elevation: 4,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
const SizedBox(height: 24),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.grey[100],
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade300),
),
child: Text(
_status,
style: const TextStyle(fontSize: 12, color: Colors.black54),
textAlign: TextAlign.center,
),
),
]),
),
],
);
}
Widget _buildBottomActionBar() {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: const Offset(0, -5))],
),
child: ElevatedButton.icon(
onPressed: _handleGenerate,
icon: const Icon(Icons.picture_as_pdf),
label: const Text("伝票を確定してPDF生成"),
style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 60),
backgroundColor: Colors.indigo,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
),
);
}
}
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 '../services/invoice_repository.dart';
import '../services/customer_repository.dart';
import 'product_master_screen.dart';
class ManagementScreen extends StatelessWidget {
const ManagementScreen({Key? key}) : super(key: key);
@ -20,6 +21,13 @@ class ManagementScreen extends StatelessWidget {
body: ListView(
children: [
_buildSectionHeader("データ入出力"),
_buildMenuTile(
context,
Icons.inventory_2,
"商品マスター管理",
"販売商品の名称や単価を管理します",
() => Navigator.push(context, MaterialPageRoute(builder: (context) => const ProductMasterScreen())),
),
_buildMenuTile(
context,
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 '../models/invoice_models.dart';
import '../models/product_model.dart';
import '../services/product_repository.dart';
import 'product_master_screen.dart';
///
class ProductPickerModal extends StatefulWidget {
@ -12,14 +15,24 @@ class ProductPickerModal extends StatefulWidget {
}
class _ProductPickerModalState extends State<ProductPickerModal> {
//
final List<InvoiceItem> _masterProducts = [
InvoiceItem(description: "技術料", quantity: 1, unitPrice: 50000),
InvoiceItem(description: "部品代 A", quantity: 1, unitPrice: 15000),
InvoiceItem(description: "部品代 B", quantity: 1, unitPrice: 3000),
InvoiceItem(description: "出張費", quantity: 1, unitPrice: 10000),
InvoiceItem(description: "諸経費", quantity: 1, unitPrice: 5000),
];
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;
});
}
@override
Widget build(BuildContext context) {
@ -42,19 +55,55 @@ class _ProductPickerModalState extends State<ProductPickerModal> {
),
const Divider(),
Expanded(
child: ListView.builder(
itemCount: _masterProducts.length,
itemBuilder: (context, index) {
final product = _masterProducts[index];
return ListTile(
leading: const Icon(Icons.inventory_2_outlined),
title: Text(product.description),
subtitle: Text("単価: ¥${product.unitPrice}"),
onTap: () => widget.onItemSelected(product),
);
},
),
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _products.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("商品マスターが空です"),
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');
return await openDatabase(
path,
version: 1,
version: 2,
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 {
//
await db.execute('''
@ -72,6 +79,7 @@ class DatabaseHelper {
notes TEXT,
file_path TEXT,
total_amount INTEGER,
tax_rate REAL DEFAULT 0.10,
odoo_id TEXT,
is_synced INTEGER DEFAULT 0,
updated_at TEXT NOT NULL,

View file

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

View file

@ -133,7 +133,7 @@ Future<String?> generateInvoicePdf(Invoice invoice) async {
children: [
pw.SizedBox(height: 10),
_buildSummaryRow("小計 (税抜)", amountFormatter.format(invoice.subtotal)),
_buildSummaryRow("消費税 (10%)", amountFormatter.format(invoice.tax)),
_buildSummaryRow("消費税 (${(invoice.taxRate * 100).toInt()}%)", amountFormatter.format(invoice.tax)),
pw.Divider(),
_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 @@
- 商品マスター管理画面の実装
- 伝票入力画面の実装
- 伝票入力はあれもこれも盛り込みたいので実験的に色んなのを実装
商品マスター編集画面の実装
- 顧客マスター編集画面の実装