h-1.flutter.4/lib/screens/estimate_screen.dart

249 lines
No EOL
7.5 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Version: 1.0.0 - EstimateScreen 見積入力画面
import 'package:flutter/material.dart';
import '../models/estimate.dart';
import '../services/database_helper.dart';
import '../models/product.dart';
/// 見積入力画面Material Design テンプレート)
class EstimateScreen extends StatefulWidget {
const EstimateScreen({super.key});
@override
State<EstimateScreen> createState() => _EstimateScreenState();
}
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));
}
Future<void> _saveEstimate() async {
if (_items.isEmpty) return;
// データベースへの保存
try {
final estimatedNo = 'EST-${DateTime.now().year}${DateTime.now().month.toString().padLeft(2, '0')}-${_items.length + 1}';
await _db.insertEstimate(
estimateNo: estimatedNo,
customerName: _selectedCustomer?.name ?? '未指定',
date: DateTime.now(),
items: _items,
);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('見積を保存しました'))..behavior: SnackBarBehavior.floating,
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
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('見積入力'),
actions: [
IconButton(
icon: const Icon(Icons.save),
onPressed: _saveEstimate,
),
],
),
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)),
),
),
),
],
),
),
),
if (_selectedCustomer != null) ...[
const SizedBox(height: 16),
Card(
child: ListTile(
title: const Text('得意先'),
subtitle: Text(_selectedCustomer!.name),
),
),
],
],
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add_shopping_cart),
label: const Text('商品追加'),
onPressed: () => _showAddDialog(),
),
);
}
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});
}