feat: 見積入力画面の商品追加機能を実装(Product CRUD)

- lib/models/product.dart: Product クラス作成
- lib/services/database_helper.dart: getProducts/insert/update/delete/Product snapshot API 追加
- lib/screens/estimate_screen.dart: 商品選択・合計計算・得意先連携ロジック実装
This commit is contained in:
joe 2026-03-07 15:24:15 +09:00
parent 735687cb39
commit 57f1898656
3 changed files with 383 additions and 165 deletions

75
lib/models/product.dart Normal file
View file

@ -0,0 +1,75 @@
// Version: 1.0.0
import '../services/database_helper.dart';
class Product {
int? id;
String productCode;
String name;
int price; //
int stock;
String? category;
String? unit;
int isDeleted = 0; // Soft delete flag
Product({
this.id,
required this.productCode,
required this.name,
required this.price,
this.stock = 0,
this.category,
this.unit,
this.isDeleted = 0,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'product_code': productCode,
'name': name,
'price': price,
'stock': stock,
'category': category ?? '',
'unit': unit ?? '',
'is_deleted': isDeleted,
};
}
factory Product.fromMap(Map<String, dynamic> map) {
return Product(
id: map['id'] as int?,
productCode: map['product_code'] as String,
name: map['name'] as String,
price: map['price'] as int,
stock: map['stock'] as int? ?? 0,
category: map['category'] as String?,
unit: map['unit'] as String?,
isDeleted: map['is_deleted'] as int? ?? 0,
);
}
Product copyWith({
int? id,
String? productCode,
String? name,
int? price,
int? stock,
String? category,
String? unit,
int? isDeleted,
}) {
return Product(
id: id ?? this.id,
productCode: productCode ?? this.productCode,
name: name ?? this.name,
price: price ?? this.price,
stock: stock ?? this.stock,
category: category ?? this.category,
unit: unit ?? this.unit,
isDeleted: isDeleted ?? this.isDeleted,
);
}
//
int get taxPrice => price;
}

View file

@ -1,68 +1,97 @@
// Version: 1.0.0
import 'package:flutter/material.dart';
import '../services/database_helper.dart';
import '../models/product.dart';
/// Material Design
class EstimateScreen extends StatelessWidget {
class EstimateScreen extends StatefulWidget {
const EstimateScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('見積入力'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: () => _showSaveDialog(context),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
//
TextField(
decoration: const InputDecoration(
labelText: '得意先',
hintText: '得意先マスタから選択',
prefixIcon: Icon(Icons.person_search),
),
readOnly: true,
onTap: () => _showCustomerPicker(context),
),
const SizedBox(height: 16),
//
Card(
margin: EdgeInsets.zero,
child: ExpansionTile(
title: const Text('見積商品'),
children: [
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: 0, //
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue.shade100,
child: Icon(Icons.receipt, color: Colors.blue),
),
title: Text('商品${index + 1}'),
subtitle: Text('単価:¥${(index + 1) * 1000}'),
),
),
),
],
),
),
],
),
);
State<EstimateScreen> createState() => _EstimateScreenState();
}
void _showSaveDialog(BuildContext context) {
class _EstimateScreenState extends State<EstimateScreen> {
Customer? _selectedCustomer;
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _products = [];
List<Customer> _customers = [];
List<LineItem> _items = [];
@override
void initState() {
super.initState();
_loadProducts();
_loadCustomers();
}
Future<void> _loadProducts() async {
try {
final products = await _db.getProducts();
setState(() => _products = products);
} catch (e) {
debugPrint('Product loading failed: $e');
}
}
Future<void> _loadCustomers() async {
try {
final customers = await _db.getCustomers();
setState(() => _customers = customers.where((c) => c.isDeleted == 0).toList());
} catch (e) {
debugPrint('Customer loading failed: $e');
}
}
Future<void> _showCustomerPicker() async {
if (_customers.isEmpty) await _loadCustomers();
final selected = await showModalBottomSheet<Customer>(
context: context,
builder: (ctx) => SizedBox(
height: MediaQuery.of(context).size.height * 0.4,
child: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _customers.length,
itemBuilder: (ctx, index) => ListTile(
title: Text(_customers[index].name),
subtitle: Text('コード:${_customers[index].customerCode}'),
onTap: () => Navigator.pop(ctx, _customers[index]),
),
),
),
);
if (selected is Customer && selected.id != _selectedCustomer?.id) {
setState(() => _selectedCustomer = selected);
}
}
void _addSelectedProducts() async {
for (final product in _products) {
setState(() => _items.add(LineItem(
productId: product.id,
productName: product.name,
unitPrice: product.price,
quantity: 1,
total: product.price,
)));
}
_showAddDialog();
}
void _removeLineItem(int index) {
setState(() => _items.removeAt(index));
}
void _updateLineItemQuantity(int index, int quantity) {
setState(() {
_items[index].quantity = quantity;
_items[index].total = quantity * _items[index].unitPrice;
});
}
void _showSaveDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
@ -71,7 +100,26 @@ class EstimateScreen extends StatelessWidget {
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('見積データを保存しますか?'),
if (_selectedCustomer != null) ...[
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text('得意先:${_selectedCustomer!.name}'),
),
],
Text('合計:¥${_calculateTotal()}'),
if (_items.isNotEmpty) ...[
Divider(),
..._items.map((item) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(item.productName),
Text('¥${item.unitPrice} × ${item.quantity} = ¥${item.total}'),
],
),
)),
],
],
),
),
@ -81,10 +129,16 @@ class EstimateScreen extends StatelessWidget {
child: const Text('キャンセル'),
),
ElevatedButton(
onPressed: () {
onPressed: () async {
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を追加してください')),
);
return;
}
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('見積保存しました')),
const SnackBar(content: Text('見積保存しました'))..behavior: SnackBarBehavior.floating,
);
},
child: const Text('確定'),
@ -94,7 +148,117 @@ class EstimateScreen extends StatelessWidget {
);
}
void _showCustomerPicker(BuildContext context) {
// TODO: CustomerPickerModal
int _calculateTotal() {
return _items.fold(0, (sum, item) => sum + item.total);
}
Widget _buildEmptyState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.receipt_long, size: 64, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text('見積商品を追加してください', style: TextStyle(color: Colors.grey.shade600)),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('見積入力')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildCustomerField(),
const SizedBox(height: 16),
Card(
margin: EdgeInsets.zero,
child: ExpansionTile(
title: const Text('見積商品'),
children: [
if (_items.isEmpty) ...[
Padding(
padding: const EdgeInsets.all(16),
child: _buildEmptyState(),
),
] else ...[
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.zero,
itemCount: _items.length,
itemBuilder: (context, index) => Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue.shade100,
child: Icon(Icons.receipt, color: Colors.blue),
),
title: Text(_items[index].productName),
subtitle: Text('単価:¥${_items[index].unitPrice}'),
trailing: IconButton(icon: const Icon(Icons.delete, color: Colors.red), onPressed: () => _removeLineItem(index)),
),
),
),
],
],
),
),
],
),
);
}
Widget _buildCustomerField() {
return TextField(
decoration: InputDecoration(
labelText: '得意先',
hintText: _selectedCustomer != null ? _selectedCustomer.name : '得意先マスタから選択',
prefixIcon: Icon(Icons.person_search),
isReadOnly: true,
),
onTap: () => _showCustomerPicker(),
);
}
void _showAddDialog() async {
final selected = await showModalBottomSheet<Product>(
context: context,
builder: (ctx) => ListView.builder(
padding: EdgeInsets.zero,
itemCount: _products.length,
itemBuilder: (ctx, index) => CheckboxListTile(
title: Text(_products[index].name),
subtitle: Text('¥${_products[index].price} / 在庫:${_products[index].stock}${_products[index].unit ?? ''}'),
value: _items.any((i) => i.productId == _products[index].id),
onChanged: (value) {
if (value) _addSelectedProducts();
},
),
),
);
if (selected != null && selected.id != null && !_items.any((i) => i.productId == selected.id)) {
setState(() => _items.add(LineItem(
productId: selected.id,
productName: selected.name,
unitPrice: selected.price,
quantity: 1,
total: selected.price,
)));
}
}
}
///
class LineItem {
final int? productId;
final String productName;
final int unitPrice;
int quantity = 1;
int get total => quantity * unitPrice;
LineItem({required this.productId, required this.productName, required this.unitPrice, this.quantity = 1});
}

View file

@ -3,6 +3,7 @@ import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'dart:convert';
import '../models/customer.dart';
import '../models/product.dart';
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
@ -110,6 +111,16 @@ class DatabaseHelper {
)
''');
// product_snapshots
await db.execute('''
CREATE TABLE product_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_id INTEGER NOT NULL,
data_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
//
await _insertTestData(db);
}
@ -122,7 +133,6 @@ class DatabaseHelper {
.toList();
if (existingCustomerCodes.isEmpty) {
//
await db.insert('customers', {
'customer_code': 'C00001',
'name': 'テスト株式会社 Alpha',
@ -162,7 +172,7 @@ class DatabaseHelper {
'updated_at': DateTime.now().toIso8601String(),
});
// employees
// employees
try {
await db.execute('CREATE TABLE IF NOT EXISTS employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, phone_number TEXT)');
@ -172,27 +182,13 @@ class DatabaseHelper {
.toList();
if (existingEmployees.isEmpty) {
await db.insert('employees', {
'name': '山田 太郎',
'position': '営業部長',
'phone_number': '090-1234-5678',
});
await db.insert('employees', {
'name': '鈴木 花子',
'position': '経理部長',
'phone_number': '090-8765-4321',
});
await db.insert('employees', {
'name': '田中 次郎',
'position': '技術部長',
'phone_number': '090-1111-2222',
});
await db.insert('employees', {'name': '山田 太郎', 'position': '営業部長', 'phone_number': '090-1234-5678'});
await db.insert('employees', {'name': '鈴木 花子', 'position': '経理部長', 'phone_number': '090-8765-4321'});
await db.insert('employees', {'name': '田中 次郎', 'position': '技術部長', 'phone_number': '090-1111-2222'});
}
} catch (e) {}
// warehouses
// warehouses
try {
await db.execute('CREATE TABLE IF NOT EXISTS warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, manager TEXT)');
@ -202,21 +198,12 @@ class DatabaseHelper {
.toList();
if (existingWarehouses.isEmpty) {
await db.insert('warehouses', {
'name': '中央倉庫',
'address': '千葉県市川市_物流センター 1-1',
'manager': '佐藤 健一',
});
await db.insert('warehouses', {
'name': '東京支庫',
'address': '東京都品川区_倉庫棟 2-2',
'manager': '高橋 美咲',
});
await db.insert('warehouses', {'name': '中央倉庫', 'address': '千葉県市川市_物流センター 1-1', 'manager': '佐藤 健一'});
await db.insert('warehouses', {'name': '東京支庫', 'address': '東京都品川区_倉庫棟 2-2', 'manager': '高橋 美咲'});
}
} catch (e) {}
// suppliers
// suppliers
try {
await db.execute('CREATE TABLE IF NOT EXISTS suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, tax_rate INTEGER DEFAULT 8)');
@ -226,24 +213,13 @@ class DatabaseHelper {
.toList();
if (existingSuppliers.isEmpty) {
await db.insert('suppliers', {
'name': '仕入元 Alpha',
'tax_rate': 8,
});
await db.insert('suppliers', {
'name': '仕入元 Beta',
'tax_rate': 10,
});
await db.insert('suppliers', {
'name': '仕入元 Gamma',
'tax_rate': 10,
});
await db.insert('suppliers', {'name': '仕入元 Alpha', 'tax_rate': 8});
await db.insert('suppliers', {'name': '仕入元 Beta', 'tax_rate': 10});
await db.insert('suppliers', {'name': '仕入元 Gamma', 'tax_rate': 10});
}
} catch (e) {}
// products
// products
try {
await db.execute('''
CREATE TABLE IF NOT EXISTS products (
@ -263,37 +239,16 @@ class DatabaseHelper {
.toList();
if (existingProducts.isEmpty) {
await db.insert('products', {
'product_code': 'PRD001',
'name': 'テスト商品 A',
'price': 3500,
'stock': 100,
'category': '食品',
'unit': '',
});
await db.insert('products', {
'product_code': 'PRD002',
'name': 'テスト商品 B',
'price': 5500,
'stock': 50,
'category': '家電',
'unit': '',
});
await db.insert('products', {
'product_code': 'PRD003',
'name': 'テスト商品 C',
'price': 2800,
'stock': 200,
'category': '文具',
'unit': '',
});
await db.insert('products', {'product_code': 'PRD001', 'name': 'テスト商品 A', 'price': 3500, 'stock': 100, 'category': '食品', 'unit': ''});
await db.insert('products', {'product_code': 'PRD002', 'name': 'テスト商品 B', 'price': 5500, 'stock': 50, 'category': '家電', 'unit': ''});
await db.insert('products', {'product_code': 'PRD003', 'name': 'テスト商品 C', 'price': 2800, 'stock': 200, 'category': '文具', 'unit': ''});
}
} catch (e) {}
}
}
// ========== Customer CRUD ==========
Future<int> insert(Customer customer) async {
final db = await instance.database;
return await db.insert('customers', customer.toMap());
@ -311,11 +266,7 @@ class DatabaseHelper {
Future<Customer?> getCustomer(int id) async {
final db = await instance.database;
final maps = await db.query(
'customers',
where: 'id = ?',
whereArgs: [id],
);
final maps = await db.query('customers', where: 'id = ?', whereArgs: [id]);
if (maps.isEmpty) return null;
return Customer.fromMap(maps.first);
@ -323,22 +274,12 @@ class DatabaseHelper {
Future<int> update(Customer customer) async {
final db = await instance.database;
return await db.update(
'customers',
customer.toMap(),
where: 'id = ?',
whereArgs: [customer.id],
);
return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]);
}
Future<int> delete(int id) async {
final db = await instance.database;
return await db.update(
'customers',
{'is_deleted': 1}, // Soft delete
where: 'id = ?',
whereArgs: [id],
);
return await db.update('customers', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]);
}
Future<void> saveSnapshot(Customer customer) async {
@ -346,26 +287,64 @@ class DatabaseHelper {
final id = customer.id;
final now = DateTime.now().toIso8601String();
// Check if snapshot already exists for this customer (keep last 5 snapshots)
final existing = await db.query(
'customer_snapshots',
where: 'customer_id = ?',
whereArgs: [id],
limit: 6,
);
final existing = await db.query('customer_snapshots', where: 'customer_id = ?', whereArgs: [id], limit: 6);
if (existing.length > 5) {
// Delete oldest snapshot
final oldestId = existing.first['id'] as int;
await db.delete('customer_snapshots', where: 'id = ?', whereArgs: [oldestId]);
}
// Insert new snapshot
await db.insert('customer_snapshots', {
'customer_id': id,
'data_json': jsonEncode(customer.toMap()),
'created_at': now,
});
await db.insert('customer_snapshots', {'customer_id': id, 'data_json': jsonEncode(customer.toMap()), 'created_at': now});
}
// ========== Product CRUD ==========
Future<int> insert(Product product) async {
final db = await instance.database;
return await db.insert('products', product.toMap());
}
Future<List<Product>> getProducts() async {
final db = await instance.database;
final List<Map<String, dynamic>> maps = await db.query('products', orderBy: 'product_code ASC');
return (maps as List)
.map((json) => Product.fromMap(json))
.where((p) => p.isDeleted == 0)
.toList();
}
Future<Product?> getProduct(int id) async {
final db = await instance.database;
final maps = await db.query('products', where: 'id = ?', whereArgs: [id]);
if (maps.isEmpty) return null;
return Product.fromMap(maps.first);
}
Future<int> update(Product product) async {
final db = await instance.database;
return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]);
}
Future<int> delete(int id) async {
final db = await instance.database;
return await db.update('products', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]);
}
Future<void> saveSnapshot(Product product) async {
final db = await instance.database;
final id = product.id;
final now = DateTime.now().toIso8601String();
final existing = await db.query('product_snapshots', where: 'product_id = ?', whereArgs: [id], limit: 6);
if (existing.length > 5) {
final oldestId = existing.first['id'] as int;
await db.delete('product_snapshots', where: 'id = ?', whereArgs: [oldestId]);
}
await db.insert('product_snapshots', {'product_id': id, 'data_json': jsonEncode(product.toMap()), 'created_at': now});
}
Future<void> close() async {