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 // Version: 1.0.0
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/database_helper.dart';
import '../models/product.dart';
/// Material Design /// Material Design
class EstimateScreen extends StatelessWidget { class EstimateScreen extends StatefulWidget {
const EstimateScreen({super.key}); const EstimateScreen({super.key});
@override @override
Widget build(BuildContext context) { State<EstimateScreen> createState() => _EstimateScreenState();
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),
// class _EstimateScreenState extends State<EstimateScreen> {
Card( Customer? _selectedCustomer;
margin: EdgeInsets.zero, final DatabaseHelper _db = DatabaseHelper.instance;
child: ExpansionTile( List<Product> _products = [];
title: const Text('見積商品'), List<Customer> _customers = [];
children: [ List<LineItem> _items = [];
ListView.builder(
shrinkWrap: true, @override
padding: EdgeInsets.zero, void initState() {
itemCount: 0, // super.initState();
itemBuilder: (context, index) => Card( _loadProducts();
margin: const EdgeInsets.symmetric(vertical: 4), _loadCustomers();
child: ListTile(
leading: CircleAvatar(
backgroundColor: Colors.blue.shade100,
child: Icon(Icons.receipt, color: Colors.blue),
),
title: Text('商品${index + 1}'),
subtitle: Text('単価:¥${(index + 1) * 1000}'),
),
),
),
],
),
),
],
),
);
} }
void _showSaveDialog(BuildContext context) { 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( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
@ -71,7 +100,26 @@ class EstimateScreen extends StatelessWidget {
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ 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('キャンセル'), child: const Text('キャンセル'),
), ),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () async {
if (_items.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を追加してください')),
);
return;
}
Navigator.pop(ctx); Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('見積保存しました')), const SnackBar(content: Text('見積保存しました'))..behavior: SnackBarBehavior.floating,
); );
}, },
child: const Text('確定'), child: const Text('確定'),
@ -94,7 +148,117 @@ class EstimateScreen extends StatelessWidget {
); );
} }
void _showCustomerPicker(BuildContext context) { int _calculateTotal() {
// TODO: CustomerPickerModal 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 'package:path/path.dart';
import 'dart:convert'; import 'dart:convert';
import '../models/customer.dart'; import '../models/customer.dart';
import '../models/product.dart';
class DatabaseHelper { class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init(); static final DatabaseHelper instance = DatabaseHelper._init();
@ -29,7 +30,7 @@ class DatabaseHelper {
Future<void> _createDB(Database db, int version) async { Future<void> _createDB(Database db, int version) async {
const textType = 'TEXT NOT NULL'; const textType = 'TEXT NOT NULL';
// customers // customers
await db.execute(''' await db.execute('''
CREATE TABLE customers ( CREATE TABLE customers (
@ -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); await _insertTestData(db);
} }
@ -122,7 +133,6 @@ class DatabaseHelper {
.toList(); .toList();
if (existingCustomerCodes.isEmpty) { if (existingCustomerCodes.isEmpty) {
//
await db.insert('customers', { await db.insert('customers', {
'customer_code': 'C00001', 'customer_code': 'C00001',
'name': 'テスト株式会社 Alpha', 'name': 'テスト株式会社 Alpha',
@ -162,7 +172,7 @@ class DatabaseHelper {
'updated_at': DateTime.now().toIso8601String(), 'updated_at': DateTime.now().toIso8601String(),
}); });
// employees // employees
try { try {
await db.execute('CREATE TABLE IF NOT EXISTS employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, phone_number TEXT)'); 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(); .toList();
if (existingEmployees.isEmpty) { if (existingEmployees.isEmpty) {
await db.insert('employees', { await db.insert('employees', {'name': '山田 太郎', 'position': '営業部長', 'phone_number': '090-1234-5678'});
'name': '山田 太郎', await db.insert('employees', {'name': '鈴木 花子', 'position': '経理部長', 'phone_number': '090-8765-4321'});
'position': '営業部長', await db.insert('employees', {'name': '田中 次郎', 'position': '技術部長', 'phone_number': '090-1111-2222'});
'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) {} } catch (e) {}
// warehouses // warehouses
try { try {
await db.execute('CREATE TABLE IF NOT EXISTS warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, manager TEXT)'); 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(); .toList();
if (existingWarehouses.isEmpty) { if (existingWarehouses.isEmpty) {
await db.insert('warehouses', { await db.insert('warehouses', {'name': '中央倉庫', 'address': '千葉県市川市_物流センター 1-1', 'manager': '佐藤 健一'});
'name': '中央倉庫', await db.insert('warehouses', {'name': '東京支庫', 'address': '東京都品川区_倉庫棟 2-2', 'manager': '高橋 美咲'});
'address': '千葉県市川市_物流センター 1-1',
'manager': '佐藤 健一',
});
await db.insert('warehouses', {
'name': '東京支庫',
'address': '東京都品川区_倉庫棟 2-2',
'manager': '高橋 美咲',
});
} }
} catch (e) {} } catch (e) {}
// suppliers // suppliers
try { try {
await db.execute('CREATE TABLE IF NOT EXISTS suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, tax_rate INTEGER DEFAULT 8)'); 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(); .toList();
if (existingSuppliers.isEmpty) { if (existingSuppliers.isEmpty) {
await db.insert('suppliers', { await db.insert('suppliers', {'name': '仕入元 Alpha', 'tax_rate': 8});
'name': '仕入元 Alpha', await db.insert('suppliers', {'name': '仕入元 Beta', 'tax_rate': 10});
'tax_rate': 8, await db.insert('suppliers', {'name': '仕入元 Gamma', 'tax_rate': 10});
});
await db.insert('suppliers', {
'name': '仕入元 Beta',
'tax_rate': 10,
});
await db.insert('suppliers', {
'name': '仕入元 Gamma',
'tax_rate': 10,
});
} }
} catch (e) {} } catch (e) {}
// products // products
try { try {
await db.execute(''' await db.execute('''
CREATE TABLE IF NOT EXISTS products ( CREATE TABLE IF NOT EXISTS products (
@ -263,37 +239,16 @@ class DatabaseHelper {
.toList(); .toList();
if (existingProducts.isEmpty) { if (existingProducts.isEmpty) {
await db.insert('products', { await db.insert('products', {'product_code': 'PRD001', 'name': 'テスト商品 A', 'price': 3500, 'stock': 100, 'category': '食品', 'unit': ''});
'product_code': 'PRD001', await db.insert('products', {'product_code': 'PRD002', 'name': 'テスト商品 B', 'price': 5500, 'stock': 50, 'category': '家電', 'unit': ''});
'name': 'テスト商品 A', await db.insert('products', {'product_code': 'PRD003', 'name': 'テスト商品 C', 'price': 2800, 'stock': 200, 'category': '文具', 'unit': ''});
'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) {} } catch (e) {}
} }
} }
// ========== Customer CRUD ==========
Future<int> insert(Customer customer) async { Future<int> insert(Customer customer) async {
final db = await instance.database; final db = await instance.database;
return await db.insert('customers', customer.toMap()); return await db.insert('customers', customer.toMap());
@ -311,11 +266,7 @@ class DatabaseHelper {
Future<Customer?> getCustomer(int id) async { Future<Customer?> getCustomer(int id) async {
final db = await instance.database; final db = await instance.database;
final maps = await db.query( final maps = await db.query('customers', where: 'id = ?', whereArgs: [id]);
'customers',
where: 'id = ?',
whereArgs: [id],
);
if (maps.isEmpty) return null; if (maps.isEmpty) return null;
return Customer.fromMap(maps.first); return Customer.fromMap(maps.first);
@ -323,22 +274,12 @@ class DatabaseHelper {
Future<int> update(Customer customer) async { Future<int> update(Customer customer) async {
final db = await instance.database; final db = await instance.database;
return await db.update( return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]);
'customers',
customer.toMap(),
where: 'id = ?',
whereArgs: [customer.id],
);
} }
Future<int> delete(int id) async { Future<int> delete(int id) async {
final db = await instance.database; final db = await instance.database;
return await db.update( return await db.update('customers', {'is_deleted': 1}, where: 'id = ?', whereArgs: [id]);
'customers',
{'is_deleted': 1}, // Soft delete
where: 'id = ?',
whereArgs: [id],
);
} }
Future<void> saveSnapshot(Customer customer) async { Future<void> saveSnapshot(Customer customer) async {
@ -346,26 +287,64 @@ class DatabaseHelper {
final id = customer.id; final id = customer.id;
final now = DateTime.now().toIso8601String(); 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) { if (existing.length > 5) {
// Delete oldest snapshot
final oldestId = existing.first['id'] as int; final oldestId = existing.first['id'] as int;
await db.delete('customer_snapshots', where: 'id = ?', whereArgs: [oldestId]); 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()), // ========== Product CRUD ==========
'created_at': now,
}); 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 { Future<void> close() async {