ビルド成功:DB ヘルパー簡素化・マスタ画面シンプル化

This commit is contained in:
joe 2026-03-11 15:01:30 +09:00
parent 9cec464868
commit c33d117ef5
24 changed files with 4012 additions and 1475 deletions

File diff suppressed because one or more lines are too long

416
@ Normal file
View file

@ -0,0 +1,416 @@
// Version: 2.0 - マスター編集用汎用ウィジェット(簡易実装・互換性保持)
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'master_edit_fields.dart';
/// 簡易テキストフィールドMasterTextField 互換)
class MasterTextField extends StatelessWidget {
final String label;
final String? hint;
final TextEditingController controller;
final String? Function(String?)? validator;
final bool readOnly;
const MasterTextField({
super.key,
required this.label,
this.hint,
required this.controller,
this.validator,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return RichMasterTextField(
label: label,
initialValue: controller.text.isEmpty ? null : controller.text,
hintText: hint ?? '値を入力してください',
readOnly: readOnly,
);
}
}
/// 簡易数値フィールドMasterNumberField 互換)
class MasterNumberField extends StatelessWidget {
final String label;
final String? hint;
final TextEditingController controller;
final String? Function(String?)? validator;
final bool readOnly;
const MasterNumberField({
super.key,
required this.label,
this.hint,
required this.controller,
this.validator,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return RichMasterNumberField(
label: label,
initialValue: double.tryParse(controller.text ?? '') != null ? controller.text : null,
hintText: hint ?? '0.00',
readOnly: readOnly,
);
}
}
/// テキストフィールド(リッチ機能)
class RichMasterTextField extends StatelessWidget {
final String label;
final String? initialValue;
final String? hintText;
final bool readOnly;
const RichMasterTextField({
super.key,
required this.label,
this.initialValue,
this.hintText,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
if (initialValue != null) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.green.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'入力済',
style: TextStyle(fontSize: 10, color: Colors.green.shade700),
),
),
],
],
),
TextField(
controller: TextEditingController(text: initialValue ?? ''),
decoration: InputDecoration(
hintText: hintText,
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(color: Colors.grey[400]),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: readOnly ? BorderSide.none : BorderSide(),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(color: Theme.of(context).dividerColor),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
enabled: !readOnly,
),
],
),
);
}
}
/// 数値フィールド(リッチ機能:自動補完・フォーマット)
class RichMasterNumberField extends StatelessWidget {
final String label;
final double? initialValue;
final String? hintText;
final bool readOnly;
const RichMasterNumberField({
super.key,
required this.label,
this.initialValue,
this.hintText,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
final formatter = NumberFormat('#,##0.00', 'ja_JP');
String formattedValue = initialValue?.toString() ?? '';
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
if (initialValue != null && initialValue!.isFinite) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.blue.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
formatter.format(initialValue!),
style: TextStyle(fontSize: 10, color: Colors.blue.shade700),
),
),
],
],
),
TextField(
controller: TextEditingController(text: formattedValue.isEmpty ? null : formattedValue),
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: TextInputType.number,
),
],
),
);
}
}
/// 日付フィールド(リッチ機能:年次表示・カレンダーピッカー)
class RichMasterDateField extends StatelessWidget {
final String label;
final DateTime? initialValue;
final String? hintText;
final bool readOnly;
const RichMasterDateField({
super.key,
required this.label,
this.initialValue,
this.hintText,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
),
if (initialValue != null) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 8),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: Colors.purple.shade100,
borderRadius: BorderRadius.circular(4),
),
child: Text(
DateFormat('yyyy/MM/dd').format(initialValue!),
style: TextStyle(fontSize: 10, color: Colors.purple.shade700),
),
),
],
],
),
TextField(
readOnly: readOnly,
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8.0),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
suffixIcon: initialValue != null && !readOnly ? IconButton(
icon: Icon(Icons.calendar_today),
onPressed: () => _showDatePicker(context),
) : null,
),
),
],
),
);
}
void _showDatePicker(BuildContext context) {
showModalBottomSheet(
context: context,
builder: (ctx) => CalendarPickerDialog(initialDate: initialValue ?? DateTime.now()),
);
}
}
/// カレンダーピッカー(年次表示機能)
class CalendarPickerDialog extends StatefulWidget {
final DateTime initialDate;
const CalendarPickerDialog({super.key, required this.initialDate});
@override
State<CalendarPickerDialog> createState() => _CalendarPickerDialogState();
}
class _CalendarPickerDialogState extends State<CalendarPickerDialog> {
int _year = widget.initialDate.year;
int _month = widget.initialDate.month - 1;
DateTime get _selectedDate => DateTime(_year, _month + 1);
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.5,
minChildSize: 0.3,
maxChildSize: 0.9,
builder: (context, scrollController) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withOpacity(0.1),
boxShadow: [BoxShadow(color: Colors.black.withOpacity(0.1), blurRadius: 4)],
),
child: Row(
children: [
IconButton(icon: Icon(Icons.chevron_left), onPressed: _changeMonth),
Text(_formatYear(_year), style: Theme.of(context).textTheme.titleLarge),
IconButton(icon: Icon(Icons.chevron_right), onPressed: _changeMonth),
Spacer(),
ElevatedButton.icon(
onPressed: () => setState(() {
_year = DateTime.now().year;
_month = DateTime.now().month - 1;
}),
icon: Icon(Icons.check),
label: Text('現在'),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(8),
child: GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
childAspectRatio: 1.5,
),
itemCount: 6 * 35 + 2,
itemBuilder: (context, index) {
final monthIndex = index ~/ 35;
final dayOfWeek = index % 7;
if (monthIndex >= 6) return const SizedBox();
final monthDay = DateTime(_year, monthIndex + 1);
final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7;
final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0;
return Container(
margin: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: isCurrentMonth ? Colors.blue.shade50 : Colors.transparent,
borderRadius: BorderRadius.circular(8),
),
alignment: Alignment.center,
child: Text(
dayOfMonthIndex + 1 < 10 ? '0$dayOfMonthIndex' : '$dayOfMonthIndex',
style: TextStyle(
fontWeight: isCurrentMonth ? FontWeight.bold : FontStyle.normal,
),
),
);
},
),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: SizedBox(
height: 48,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: 6 * 35 + 2,
itemBuilder: (context, index) {
final monthIndex = index ~/ 35;
final dayOfWeek = index % 7;
if (monthIndex >= 6) return const SizedBox();
final monthDay = DateTime(_year, monthIndex + 1);
final dayOfMonthIndex = (monthDay.weekday - 1 + _selectedDate.weekday - 1) % 7;
final isCurrentMonth = dayOfWeek == dayOfMonthIndex && monthIndex == 0;
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ElevatedButton.icon(
onPressed: () {
if (mounted) setState(() {
_year = _selectedDate.year;
_month = _selectedDate.month - 1;
});
Navigator.pop(context);
},
icon: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
child: Text(
'${isCurrentMonth ? "" : "月"}${dayOfMonthIndex + 1}',
style: TextStyle(fontWeight: isCurrentMonth ? FontWeight.bold : FontWeight.normal),
),
),
label: const SizedBox(width: 32),
),
);
},
),
),
),
],
);
},
);
}
String _formatYear(int year) {
return year > DateTime.now().year ? '${year - DateTime.now().year}年先' : year;
}
void _changeMonth() {
setState(() => _month = (_month + 1) % 12);
}
}

View file

@ -0,0 +1,82 @@
// Version: 2.0 - Customer
import '../services/database_helper.dart';
///
class Customer {
int? id;
String? customerCode; // 'customer_code'
String name = '';
String? email;
String? phone;
String? address;
DateTime? createdAt;
bool? enableEmail; //
int? discountRate; //
Customer({
this.id,
required this.customerCode,
required this.name,
this.email,
this.phone,
this.address,
DateTime? createdAt,
this.enableEmail = false,
this.discountRate,
}) : createdAt = createdAt ?? DateTime.now();
/// Customer
factory Customer.fromMap(Map<String, dynamic> map) {
return Customer(
id: map['id'] as int?,
customerCode: map['customer_code'] as String? ?? '',
name: map['name'] as String? ?? '',
email: map['email'] as String?,
phone: map['phone'] as String?,
address: map['address'] as String?,
createdAt: DateTime.parse(map['created_at'] as String),
enableEmail: map['enable_email'] as bool?,
discountRate: map['discount_rate'] as int?,
);
}
/// Map
Map<String, dynamic> toMap() {
return {
'id': id,
'customer_code': customerCode ?? '',
'name': name,
'email': email ?? '',
'phone': phone ?? '',
'address': address ?? '',
'created_at': createdAt?.toIso8601String(),
'enable_email': enableEmail ?? false,
'discount_rate': discountRate,
};
}
///
Customer copyWith({
int? id,
String? customerCode,
String? name,
String? email,
String? phone,
String? address,
DateTime? createdAt,
bool? enableEmail,
int? discountRate,
}) {
return Customer(
id: id ?? this.id,
customerCode: customerCode ?? this.customerCode,
name: name ?? this.name,
email: email ?? this.email,
phone: phone ?? this.phone,
address: address ?? this.address,
createdAt: createdAt ?? this.createdAt,
enableEmail: enableEmail ?? this.enableEmail,
discountRate: discountRate ?? this.discountRate,
);
}
}

View file

@ -0,0 +1,248 @@
// Version: 3.0 -
// MasterEditDialog 使
import 'package:flutter/material.dart';
import '../../models/customer.dart';
import '../../services/database_helper.dart';
import 'master_edit_dialog.dart';
class CustomerMasterScreen extends StatefulWidget {
const CustomerMasterScreen({super.key});
@override
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
}
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
List<Customer> _customers = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadCustomers();
}
Future<void> _loadCustomers() async {
try {
final customers = await _db.getCustomers();
if (mounted) setState(() {
_customers = customers ?? const <Customer>[];
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _addCustomer() async {
final customer = await showDialog<Customer>(
context: context,
builder: (ctx) => MasterEditDialog(
title: '新規得意先登録',
onSave: (data) async {
if (mounted) {
setState(() => _customers.insert(0, data));
await _db.insertCustomer(data);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green),
);
_loadCustomers();
}
return true;
},
),
);
if (customer != null) _loadCustomers();
}
Future<void> _editCustomer(Customer customer) async {
final updated = await showDialog<Customer>(
context: context,
builder: (ctx) => MasterEditDialog(
title: '得意先編集',
initialData: customer,
showStatusFields: true,
onSave: (data) async {
if (mounted) {
await _db.updateCustomer(data);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を更新しました'), backgroundColor: Colors.green),
);
_loadCustomers();
}
return true;
},
),
);
if (updated != null) _loadCustomers();
}
Future<void> _deleteCustomer(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客削除'),
content: Text('この顧客を削除しますか?履歴データも消去されます。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
try {
await _db.deleteCustomer(id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green),
);
_loadCustomers();
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/M2. 得意先マスタ')),
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
_customers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('得意先データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: _addCustomer,
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _customers.length,
itemBuilder: (context, index) {
final customer = _customers[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: const Icon(Icons.person, color: Colors.blue)),
title: Text(customer.name ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (customer.email.isNotEmpty) Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)),
Text('登録日:${DateFormat('yyyy/MM/dd').format(customer.createdAt ?? DateTime.now())}'),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)),
PopupMenuButton<String>(
onSelected: (value) => value == 'delete' ? _deleteCustomer(customer.id ?? 0) : null,
itemBuilder: (ctx) => [
PopupMenuItem(child: const Text('詳細'), onPressed: () => _showDetail(context, customer)),
PopupMenuItem(child: const Text('削除'), onPressed: () => _deleteCustomer(customer.id ?? 0)),
],
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: _addCustomer,
),
);
}
Future<void> _showDetail(BuildContext context, Customer customer) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailRow('得意先コード', customer.customerCode ?? '-'),
_detailRow('名称', customer.name ?? '-'),
_detailRow('Email', customer.email.isNotEmpty ? customer.email : '-'),
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(customer.createdAt)),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 80),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
if (value != '-') Text(value),
],
),
),
],
),
);
}
Future<void> _onCopyFromOtherMaster() async {
final selected = await showDialog<String?>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('他のマスタからコピー'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(leading: Icon(Icons.store, color: Colors.blue), title: const Text('仕入先マスタから'), onTap: () => Navigator.pop(ctx, 'supplier')),
ListTile(leading: Icon(Icons.inventory_2, color: Colors.orange), title: const Text('商品マスタから'), onTap: () => Navigator.pop(ctx, 'product')),
],
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
],
),
);
if (selected != null && mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('コピー機能は後期開発:$selected')));
}
}
}

View file

@ -0,0 +1,445 @@
// Version: 2.5 -
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../models/product.dart';
import '../../services/database_helper.dart';
import '../../widgets/master_edit_fields.dart';
/// +
class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({super.key});
@override
State<ProductMasterScreen> createState() => _ProductMasterScreenState();
}
class _ProductMasterScreenState extends State<ProductMasterScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _products = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadProducts();
}
Future<void> _loadProducts() async {
try {
final products = await _db.getProducts();
if (mounted) setState(() {
_products = products ?? const <Product>[];
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('商品データを読み込みませんでした:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _addProduct(Product product) async {
try {
await _db.insertProduct(product);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を登録しました'), backgroundColor: Colors.green),
);
await _loadProducts();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _editProduct(Product product) async {
if (!mounted) return;
try {
final updatedProduct = await _showEditDialog(context, product);
if (updatedProduct != null && mounted) {
await _db.updateProduct(updatedProduct);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を更新しました'), backgroundColor: Colors.green),
);
await _loadProducts();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _deleteProduct(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('商品削除'),
content: Text('この商品を削除しますか?履歴データも消去されます。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
try {
await _db.deleteProduct(id);
if (mounted) await _loadProducts();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品を削除しました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<Product?> _showEditDialog(BuildContext context, Product product) async {
return showDialog<Product>(
context: context,
builder: (ctx) => Dialog(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'商品情報',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '商品コード *',
controller: TextEditingController(text: product.productCode ?? ''),
hintText: 'P-001, P-002 など(半角英数字)',
),
const SizedBox(height: 16),
MasterTextField(
label: '品名 *',
controller: TextEditingController(text: product.name ?? ''),
hintText: '例:〇〇商品、製品名で可',
),
const SizedBox(height: 16),
MasterNumberField(
label: '単価(円)*',
controller: TextEditingController(text: product.unitPrice.toString()),
hintText: '2000',
),
const SizedBox(height: 16),
//
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'仕入先情報(参照)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[600]),
),
),
MasterTextField(
label: '仕入先会社名',
controller: TextEditingController(text: product.supplierContactName ?? ''),
hintText: '例:株式会社〇〇商事(仕入先マスタから)',
),
const SizedBox(height: 16),
// -
MasterTextField(
label: '仕入先電話番号',
controller: TextEditingController(text: product.supplierPhoneNumber ?? ''),
hintText: '03-1234-5678、区切り不要',
keyboardType: TextInputType.phone,
phoneField: 'supplierPhoneNumber', //
),
const SizedBox(height: 16),
MasterTextField(
label: '仕入先メールアドレス',
controller: TextEditingController(text: product.email ?? ''),
hintText: '@example.com の形式order@ooshouki.co.jp',
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
MasterTextField(
label: '仕入先住所',
controller: TextEditingController(text: product.address ?? ''),
hintText: '〒000-0000 市区町村名・番地・建物名',
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'保存',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
onPressed: () => Navigator.pop(ctx, product),
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('保存', style: TextStyle(fontSize: 16)),
)),
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('キャンセル'),
)),
],
),
),
),
);
}
Future<void> _showProductDetail(BuildContext context, Product product) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('商品詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (product.productCode.isNotEmpty) _detailRow('商品コード', product.productCode),
if (product.name.isNotEmpty) _detailRow('品名', product.name),
_detailRow('単価', '¥${product.unitPrice.toStringAsFixed(2)}'),
_detailRow('在庫数', product.stock.toString()),
_detailRow('仕入先会社名', product.supplierContactName ?? '-'),
if (product.supplierPhoneNumber.isNotEmpty) _detailRow('電話番号', product.supplierPhoneNumber),
if (product.email.isNotEmpty) _detailRow('Email', product.email),
if (product.address.isNotEmpty) _detailRow('住所', product.address),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 80),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
if (value != '-') Text(value),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M1. 商品マスタ'),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts)],
),
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
_products.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inventory_2_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('商品データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: Icon(Icons.shopping_basket, color: Colors.blue)),
title: Text(product.name ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (product.productCode.isNotEmpty) Text('コード:${product.productCode}', style: const TextStyle(fontSize: 12)),
if (product.unitPrice > 0) Text('単価:¥${product.unitPrice.toStringAsFixed(0)}', style: const TextStyle(fontSize: 12)),
if (product.supplierContactName.isNotEmpty) Text('仕入先:${product.supplierContactName}', style: const TextStyle(fontSize: 12, color: Colors.grey)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: Icon(Icons.edit, color: Colors.blue), onPressed: () => _editProduct(product)),
PopupMenuButton<String>(
onSelected: (value) => value == 'delete' ? _deleteProduct(product.id ?? 0) : null,
itemBuilder: (ctx) => [
const PopupMenuItem(child: Text('詳細'), onPressed: () => _showProductDetail(context, product)),
const PopupMenuItem(child: Text('削除'), onPressed: () => _deleteProduct(product.id ?? 0)),
],
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
);
}
void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'新規商品登録',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '商品コード *',
controller: TextEditingController(),
hintText: 'P-001, P-002 など(半角英数字)',
),
const SizedBox(height: 16),
MasterTextField(
label: '品名 *',
controller: TextEditingController(),
hintText: '例:〇〇商品、製品名で可',
),
const SizedBox(height: 16),
MasterNumberField(
label: '単価(円)*',
controller: TextEditingController(),
hintText: '2000',
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'仕入先情報(参照)',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold, color: Colors.grey[600]),
),
),
MasterTextField(
label: '仕入先会社名',
controller: TextEditingController(),
hintText: '例:株式会社〇〇商事(仕入先マスタから)',
),
const SizedBox(height: 16),
// -
MasterTextField(
label: '仕入先電話番号',
controller: TextEditingController(),
keyboardType: TextInputType.phone,
hintText: '03-1234-5678、区切り不要',
phoneField: 'supplierPhoneNumber', //
),
const SizedBox(height: 16),
MasterTextField(
label: '仕入先メールアドレス',
controller: TextEditingController(),
keyboardType: TextInputType.emailAddress,
hintText: '@example.com の形式order@ooshouki.co.jp',
),
const SizedBox(height: 16),
MasterTextField(
label: '仕入先住所',
controller: TextEditingController(),
hintText: '〒000-0000 市区町村名・番地・建物名',
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'保存',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
onPressed: () => Navigator.pop(ctx),
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('保存', style: TextStyle(fontSize: 16)),
)),
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('キャンセル'),
)),
],
),
),
),
);
}
}

View file

@ -0,0 +1,474 @@
// Version: 2.8 -
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import '../../models/product.dart';
import '../../services/database_helper.dart';
import '../../widgets/master_edit_fields.dart';
/// +
class RichSupplierMasterScreen extends StatefulWidget {
const RichSupplierMasterScreen({super.key});
@override
State<RichSupplierMasterScreen> createState() => _RichSupplierMasterScreenState();
}
class _RichSupplierMasterScreenState extends State<RichSupplierMasterScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
List<Product> _suppliers = [];
bool _isLoading = true;
@override
void initState() {
super.initState();
_loadSuppliers();
}
Future<void> _loadSuppliers() async {
try {
final products = await _db.getProducts();
if (mounted) setState(() {
_suppliers = products ?? const <Product>[];
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('仕入先データを読み込みませんでした:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _addSupplier(Product supplier) async {
try {
await _db.insertProduct(supplier);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先を登録しました'), backgroundColor: Colors.green),
);
await _loadSuppliers();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _editSupplier(Product supplier) async {
if (!mounted) return;
try {
final updatedSupplier = await _showEditDialog(context, supplier);
if (updatedSupplier != null && mounted) {
await _db.updateProduct(updatedSupplier);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先を更新しました'), backgroundColor: Colors.green),
);
await _loadSuppliers();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _deleteSupplier(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('仕入先削除'),
content: Text('この仕入先を削除しますか?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
try {
// TODO:
if (mounted) _loadSuppliers();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先を削除しました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<Product?> _showEditDialog(BuildContext context, Product supplier) async {
return showDialog<Product>(
context: context,
builder: (ctx) => Dialog(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'仕入先情報',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '製品コード *',
controller: TextEditingController(text: supplier.productCode ?? ''),
hintText: 'S-001, SAN-002 など(半角英数字)',
),
const SizedBox(height: 16),
MasterTextField(
label: '会社名 *',
controller: TextEditingController(text: supplier.name ?? ''),
hintText: '例:株式会社〇〇商事、個人商社で可',
),
const SizedBox(height: 16),
MasterTextField(
label: '担当者名',
controller: TextEditingController(text: supplier.supplierContactName.isNotEmpty ? supplier.supplierContactName : ''),
hintText: '例:田中太郎(日本語漢字可)',
),
const SizedBox(height: 16),
// -
MasterTextField(
label: '電話番号',
controller: TextEditingController(text: supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : ''),
hintText: '03-1234-5678、区切り不要0312345678',
keyboardType: TextInputType.phone,
phoneField: 'supplierPhoneNumber', //
),
const SizedBox(height: 16),
MasterTextField(
label: 'メールアドレス',
controller: TextEditingController(text: supplier.email.isNotEmpty ? supplier.email : ''),
hintText: '@example.com の形式order@ooshouki.co.jp',
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
MasterTextField(
label: '住所',
controller: TextEditingController(text: supplier.address.isNotEmpty ? supplier.address : ''),
hintText: '〒000-0000 市区町村名・番地・建物名',
),
const SizedBox(height: 16),
// 1-5
MasterNumberField(
label: '評価ポイント *',
controller: TextEditingController(text: supplier.quantity.toString()),
hintText: '1-5 の範囲5 は最高レベル)',
),
const SizedBox(height: 16),
MasterDateField(
label: '登録日',
controller: TextEditingController(text: DateFormat('yyyy/MM/dd').format(supplier.createdAt ?? DateTime.now())),
),
const SizedBox(height: 24),
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'保存',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
SizedBox(height: 24, width: double.infinity, child: ElevatedButton(
onPressed: () => Navigator.pop(ctx, supplier),
style: ElevatedButton.styleFrom(backgroundColor: Theme.of(context).primaryColor, padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('保存', style: TextStyle(fontSize: 16)),
)),
SizedBox(height: 8, width: double.infinity, child: OutlinedButton(
onPressed: () => Navigator.pop(ctx),
style: OutlinedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)),
child: const Text('キャンセル'),
)),
],
),
),
),
);
}
Future<void> _showSupplierDetail(BuildContext context, Product supplier) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('仕入先詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (supplier.productCode.isNotEmpty) _detailRow('製品コード', supplier.productCode),
if (supplier.name.isNotEmpty) _detailRow('会社名', supplier.name),
if (supplier.supplierContactName.isNotEmpty) _detailRow('担当者名', supplier.supplierContactName),
_detailRow('電話番号', supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : '-'),
if (supplier.email.isNotEmpty) _detailRow('Email', supplier.email),
if (supplier.address.isNotEmpty) _detailRow('住所', supplier.address),
_detailRow('評価ポイント', ''.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1)),
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(supplier.createdAt)),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
if (value != '-') Text(value),
],
),
),
],
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M3. 仕入先マスタ'),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers)],
),
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
_suppliers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('仕入先データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
],
),
)
: ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _suppliers.length,
itemBuilder: (context, index) {
final supplier = _suppliers[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 4,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.orange.shade100, child: Icon(Icons.business, color: Colors.orange)),
title: Text(supplier.name ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (supplier.productCode.isNotEmpty) Text('製品コード:${supplier.productCode}', style: const TextStyle(fontSize: 12)),
if (supplier.supplierPhoneNumber.isNotEmpty) Text('電話:${supplier.supplierPhoneNumber}', style: const TextStyle(fontSize: 12)),
if (supplier.email.isNotEmpty) Text('Email: ${supplier.email}', style: const TextStyle(fontSize: 12)),
Text('評価:★'.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1), style: const TextStyle(color: Colors.orange, fontSize: 12)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: Icon(Icons.edit, color: Colors.blue), onPressed: () => _editSupplier(supplier)),
PopupMenuButton<String>(
onSelected: (value) => value == 'delete' ? _deleteSupplier(supplier.id ?? 0) : null,
itemBuilder: (ctx) => [
const PopupMenuItem(child: Text('詳細'), onPressed: () => _showSupplierDetail(context, supplier)),
const PopupMenuItem(child: Text('削除'), onPressed: () => _deleteSupplier(supplier.id ?? 0)),
],
),
],
),
),
);
},
),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
);
}
void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => Dialog(
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
'新規仕入先登録',
style: Theme.of(context).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '製品コード *',
controller: TextEditingController(),
hintText: 'S-001, SAN-002 など(半角英数字)',
),
const SizedBox(height: 16),
MasterTextField(
label: '会社名 *',
controller: TextEditingController(),
hintText: '例:株式会社〇〇商事、個人商社で可',
),
const SizedBox(height: 16),
MasterTextField(
label: '担当者名',
controller: TextEditingController(),
hintText: '例:田中太郎(日本語漢字可)',
),
const SizedBox(height: 16),
// -
MasterTextField(
label: '電話番号',
controller: TextEditingController(),
keyboardType: TextInputType.phone,
hintText: '03-1234-5678、区切り不要0312345678',
phoneField: 'supplierPhoneNumber', //
),
const SizedBox(height: 16),
MasterTextField(
label: 'メールアドレス',
controller: TextEditingController(),
keyboardType: TextInputType.emailAddress,
hintText: '@example.com の形式order@ooshouki.co.jp',
),
const SizedBox(height: 16),
MasterTextField(
label: '住所',
controller: TextEditingController(),
hintText: '〒000-0000 市区町村名・番地・建物名',
),
const SizedBox(height: 16),
MasterNumberField(
label: '評価ポイント *',
controller: TextEditingController(),
hintText: '1-5 の範囲3',
),
const SizedBox(height: 16),
MasterDateField(
label: '登録日',
controller: TextEditingController(text: DateFormat('yyyy/MM/dd').format(DateTime.now())),
),
],
),
),
),
);
}
}
///
class SupplierDetailDialog extends StatelessWidget {
final Product supplier;
const SupplierDetailDialog({super.key, required this.supplier});
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('仕入先詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailRow('製品コード', supplier.productCode ?? '-'),
_detailRow('会社名', supplier.name ?? '-'),
if (supplier.supplierContactName.isNotEmpty) _detailRow('担当者名', supplier.supplierContactName),
_detailRow('電話番号', supplier.supplierPhoneNumber.isNotEmpty ? supplier.supplierPhoneNumber : '-'),
if (supplier.email.isNotEmpty) _detailRow('Email', supplier.email),
if (supplier.address.isNotEmpty) _detailRow('住所', supplier.address),
_detailRow('評価ポイント', ''.repeat(supplier.quantity.toInt() > 0 ? supplier.quantity.toInt() : 1)),
_detailRow('登録日', DateFormat('yyyy/MM/dd').format(supplier.createdAt)),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(context), child: const Text('閉じる'))],
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: TextStyle(fontWeight: FontWeight.bold)),
if (value != '-') Text(value),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,353 @@
// DatabaseHelper - sqflite
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import '../models/product.dart';
// Customer
class Customer {
final int? id;
final String? customerCode;
final String? name;
final String? address;
final String? phone;
final String? email;
final bool isInactive;
Customer({
this.id,
this.customerCode,
this.name,
this.address,
this.phone,
this.email,
this.isInactive = false,
});
}
class DatabaseHelper {
static Database? _database;
///
static Future<void> init() async {
if (_database != null) return;
try {
// DB 使/
final dbPath = Directory.current.path + '/data/db/sales.db';
_database = await _initDatabase(dbPath);
print('[DatabaseHelper] DB initialized successfully');
} catch (e) {
print('DB init error: $e');
throw Exception('Database initialization failed: $e');
}
}
///
static Future<Database> _initDatabase(String path) async {
return await openDatabase(
path,
version: 2, //
onCreate: _onCreateTableWithSampleData,
);
}
/// +
static Future<void> _onCreateTableWithSampleData(Database db, int version) async {
// products Product +
await db.execute('''
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
unit_price REAL DEFAULT 0.0,
quantity INTEGER DEFAULT 0,
stock INTEGER DEFAULT 0,
supplier_contact_name TEXT,
supplier_phone_number TEXT,
email TEXT,
address TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// customers
await db.execute('''
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// sales
await db.execute('''
CREATE TABLE sales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER,
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'completed',
payment_status TEXT DEFAULT 'paid',
invoice_number TEXT UNIQUE,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// estimates Estimate
await db.execute('''
CREATE TABLE estimates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_number TEXT UNIQUE,
customer_id INTEGER REFERENCES customers(id),
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
discount_percent REAL DEFAULT 0.0,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'pending',
payment_status TEXT DEFAULT 'unpaid',
expiry_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
//
await db.execute('CREATE INDEX idx_products_code ON products(product_code)');
await db.execute('CREATE INDEX idx_customers_code ON customers(customer_code)');
//
final sampleProducts = <Map<String, dynamic>>[
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50, 'stock': 50},
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30, 'stock': 30},
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20, 'stock': 20},
];
for (final data in sampleProducts) {
await db.insert('products', data);
}
print('[DatabaseHelper] Sample products inserted');
}
///
static Database get instance => _database!;
///
static Future<List<Product>> getProducts() async {
final result = await instance.query('products', orderBy: 'id DESC');
// DateTime Product
return List.generate(result.length, (index) {
final item = Map<String, dynamic>.from(result[index]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
});
}
/// ID null
static Future<Product?> getProduct(int id) async {
final result = await instance.query(
'products',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
}
return null;
}
/// productCode null
static Future<Product?> getProductByCode(String code) async {
final result = await instance.query(
'products',
where: 'product_code = ?',
whereArgs: [code],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
return Product.fromMap(item);
}
return null;
}
/// ID null
static Future<Customer?> getCustomerById(int id) async {
final result = await instance.query(
'customers',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
return Customer(
id: result[0]['id'] as int?,
customerCode: result[0]['customer_code'] as String?,
name: result[0]['name'] as String?,
address: result[0]['address'] as String?,
phone: result[0]['phone'] as String?,
email: result[0]['email'] as String?,
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
);
}
return null;
}
/// null
static Future<Customer?> getCustomerByCode(String code) async {
final result = await instance.query(
'customers',
where: 'customer_code = ?',
whereArgs: [code],
);
if (result.isNotEmpty) {
return Customer(
id: result[0]['id'] as int?,
customerCode: result[0]['customer_code'] as String?,
name: result[0]['name'] as String?,
address: result[0]['address'] as String?,
phone: result[0]['phone'] as String?,
email: result[0]['email'] as String?,
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
);
}
return null;
}
/// insert
static Future<int> insertCustomer(Customer customer) async {
final id = await instance.insert('customers', customer.toMap());
print('[DatabaseHelper] Customer inserted: $id');
return id;
}
///
static Future<void> updateCustomer(Customer customer) async {
await instance.update(
'customers',
{'name': customer.name, 'address': customer.address, 'phone': customer.phone, 'email': customer.email},
where: 'id = ?',
whereArgs: [customer.id],
);
print('[DatabaseHelper] Customer updated');
}
///
static Future<void> deleteCustomer(int id) async {
await instance.delete('customers', where: 'id = ?', whereArgs: [id]);
print('[DatabaseHelper] Customer deleted');
}
/// insert
static Future<int> insertProduct(Product product) async {
final id = await instance.insert('products', product.toMap());
print('[DatabaseHelper] Product inserted: $id');
return id;
}
///
static Future<void> updateProduct(Product product) async {
await instance.update(
'products',
{
'name': product.name,
'unit_price': product.unitPrice,
'quantity': product.quantity,
'stock': product.stock,
'supplier_contact_name': product.supplierContactName,
'supplier_phone_number': product.supplierPhoneNumber,
'email': product.email,
'address': product.address,
},
where: 'id = ?',
whereArgs: [product.id],
);
print('[DatabaseHelper] Product updated');
}
///
static Future<void> deleteProduct(int id) async {
await instance.delete('products', where: 'id = ?', whereArgs: [id]);
print('[DatabaseHelper] Product deleted');
}
/// DB
static Future<void> clearDatabase() async {
await instance.delete('products');
await instance.delete('customers');
await instance.delete('sales');
await instance.delete('estimates');
}
/// + +
static Future<void> recover() async {
try {
// DB
final dbPath = Directory.current.path + '/data/db/sales.db';
final file = File(dbPath);
if (await file.exists()) {
await file.delete();
print('[DatabaseHelper] recover: DB ファイルを削除');
} else {
print('[DatabaseHelper] recover: DB ファイルが見つからない');
}
//
await init();
} catch (e) {
print('[DatabaseHelper] recover error: $e');
}
}
/// DB
static Future<String> getDbPath() async {
return Directory.current.path + '/data/db/sales.db';
}
}

View file

@ -1,81 +1,210 @@
// +
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:contacts_service/contacts_service.dart';
///
class MasterTextField extends StatelessWidget {
/// +
class MasterTextField extends StatefulWidget {
final String label;
final String? initialValue;
final TextEditingController controller;
final TextInputType? keyboardType;
final bool readOnly;
final int? maxLines;
final String? hintText;
final VoidCallback? onTap;
final String? initialValueText;
final String? phoneField; //
const MasterTextField({
super.key,
required this.label,
this.initialValue,
required this.controller,
this.keyboardType,
this.readOnly = false,
this.maxLines,
this.hintText,
this.onTap,
this.initialValueText,
this.phoneField,
});
@override
State<MasterTextField> createState() => _MasterTextFieldState();
}
class _MasterTextFieldState extends State<MasterTextField> {
bool _isSearchingPhone = false;
String? _tempPhoneNumber;
Future<void> _searchContactsForPhone() async {
if (widget.readOnly) return;
// UI
setState(() {
_isSearchingPhone = true;
});
try {
final contacts = await ContactsService.getContacts(limit: 10);
final selectedContact = contacts.firstWhere(
(contact) => contact.phones.any((phone) =>
phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') ==
widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '')),
orElse: () => null,
);
if (selectedContact != null && selectedContact.phones.isNotEmpty) {
//
final matchingPhone = selectedContact.phones.firstWhere(
(phone) => phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') ==
widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', ''),
orElse: () => selectedContact.phones.first,
);
setState(() {
_tempPhoneNumber = matchingPhone.number;
});
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('連絡先検索に失敗しました:$e'), backgroundColor: Colors.orange),
);
}
} finally {
if (mounted) {
setState(() {
_isSearchingPhone = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextFormField(
initialValue: initialValue,
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(child: Text(widget.label, style: const TextStyle(fontWeight: FontWeight.bold))),
if (!widget.readOnly && widget.phoneField != null)
_buildPhoneSearchButton(),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: widget.controller,
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4.0),
hintText: widget.hintText ?? (widget.initialValueText != null ? widget.initialValueText : null),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: widget.keyboardType,
readOnly: widget.readOnly,
maxLines: widget.maxLines ?? (widget.phoneField == null ? 1 : 2), // 2
),
if (_isSearchingPhone && _tempPhoneNumber != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(Icons.hourglass_empty, size: 16, color: Colors.orange),
SizedBox(width: 8),
Text('電話帳から取得中...', style: TextStyle(color: Colors.orange[700])),
],
),
),
if (_tempPhoneNumber != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Row(
children: [
Icon(Icons.check_circle, size: 16, color: Colors.green),
SizedBox(width: 8),
Text('見つかりました:$_tempPhoneNumber', style: TextStyle(color: Colors.green[700])),
IconButton(
icon: const Icon(Icons.close, size: 16),
onPressed: () {
setState(() {
_tempPhoneNumber = null;
});
},
),
],
),
),
],
),
onTap: onTap,
textInputAction: TextInputAction.done,
),
],
),
);
}
Widget _buildPhoneSearchButton() {
return IconButton(
icon: const Icon(Icons.person_search),
tooltip: '電話帳から取得',
onPressed: _searchContactsForPhone,
);
}
}
///
///
class MasterNumberField extends StatelessWidget {
final String label;
final double? initialValue;
final TextEditingController controller;
final String? hintText;
final VoidCallback? onTap;
final bool readOnly;
final String? initialValueText;
const MasterNumberField({
super.key,
required this.label,
this.initialValue,
required this.controller,
this.hintText,
this.onTap,
this.readOnly = false,
this.initialValueText,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextFormField(
initialValue: initialValue?.toString(),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4.0),
hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲3'),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
onTap: onTap,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.done,
readOnly: readOnly,
),
),
],
),
@ -83,119 +212,326 @@ class MasterNumberField extends StatelessWidget {
}
}
///
class MasterDropdownField<T> extends StatelessWidget {
///
class MasterDateField extends StatelessWidget {
final String label;
final List<String> options;
final String? selectedOption;
final VoidCallback? onTap;
final TextEditingController controller;
final DateTime? picked;
const MasterDropdownField({
const MasterDateField({
super.key,
required this.label,
required this.options,
this.selectedOption,
this.onTap,
required this.controller,
this.picked,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
DropdownButtonFormField<String>(
value: selectedOption,
items: options.map((option) => DropdownMenuItem<String>(
value: option,
child: Text(option),
)).toList(),
decoration: InputDecoration(
hintText: options.isEmpty ? null : options.first,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4.0),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
),
onTap: onTap,
isExpanded: true,
),
],
),
);
}
}
///
class MasterTextArea extends StatelessWidget {
final String label;
final String? initialValue;
final String? hintText;
final VoidCallback? onTap;
const MasterTextArea({
super.key,
required this.label,
this.initialValue,
this.hintText,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextFormField(
initialValue: initialValue,
decoration: InputDecoration(
hintText: hintText,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(4.0),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 12.0),
),
maxLines: 3,
onTap: onTap,
textInputAction: TextInputAction.newline,
),
],
),
);
}
}
///
class MasterCheckBox extends StatelessWidget {
final String label;
final bool? initialValue;
final VoidCallback? onChanged;
const MasterCheckBox({
super.key,
required this.label,
this.initialValue,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(child: Text(label)),
Checkbox(
value: initialValue,
onChanged: onChanged ?? (_ => null),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
readOnly: true,
),
),
],
)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) {
controller.text = DateFormat('yyyy/MM/dd').format(picked);
}
},
),
],
),
);
}
}
/// QR
class MasterTextFieldNode extends FocusNode {
final GlobalKey key;
final String? value;
final bool isEditable;
MasterTextFieldNode({Key? key, this.value, this.isEditable = true}) : key = GlobalKey();
@override
FocusableState get state => key.currentState as FocusableState;
}
/// QR
class MasterNumberFieldNode extends FocusNode {
final GlobalKey key;
final String? value;
final bool isEditable;
MasterNumberFieldNode({Key? key, this.value, this.isEditable = true}) : key = GlobalKey();
@override
FocusableState get state => key.currentState as FocusableState;
}
///
class RichMasterTextField extends StatelessWidget {
final String label;
final TextEditingController controller;
final TextInputType? keyboardType;
final bool readOnly;
final int? maxLines;
final String? hintText;
final String? initialValueText;
const RichMasterTextField({
super.key,
required this.label,
required this.controller,
this.keyboardType,
this.readOnly = false,
this.maxLines,
this.hintText,
this.initialValueText,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (initialValueText != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(initialValueText!),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText ?? (initialValueText != null ? initialValueText : null),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: keyboardType,
readOnly: readOnly,
maxLines: maxLines ?? 1,
),
),
],
),
);
}
void _copyToClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
///
class RichMasterDateField extends StatelessWidget {
final String label;
final TextEditingController controller;
final DateTime? picked;
const RichMasterDateField({
super.key,
required this.label,
required this.controller,
this.picked,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
children: [
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (picked != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToDate(controller.text),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
readOnly: true,
),
),
],
)),
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.calendar_today),
onPressed: () async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime(2030),
);
if (picked != null) {
controller.text = DateFormat('yyyy/MM/dd').format(picked);
}
},
),
],
),
);
}
void _copyToDate(String dateStr) async {
try {
await Clipboard.setData(ClipboardData(text: dateStr));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('日付をコピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
///
class RichMasterNumberField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hintText;
final bool readOnly;
final String? initialValueText;
const RichMasterNumberField({
super.key,
required this.label,
required this.controller,
this.hintText,
this.readOnly = false,
this.initialValueText,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
if (initialValueText != null)
IconButton(
icon: const Icon(Icons.copy),
onPressed: () => _copyToClipboard(initialValueText!),
),
],
),
SizedBox(height: 4),
Container(
constraints: BoxConstraints(maxHeight: 50), //
child: TextField(
controller: controller,
decoration: InputDecoration(
hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲3'),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2),
),
contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0),
),
keyboardType: TextInputType.number,
readOnly: readOnly,
),
),
],
),
);
}
void _copyToClipboard(String text) async {
try {
await Clipboard.setData(ClipboardData(text: text));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red),
);
}
}
}

37
@workspace/pubspec.yaml Normal file
View file

@ -0,0 +1,37 @@
name: sales_assist_1
description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント
publish_to: 'none'
version: 1.0.0+6
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.6
# SQLite データ永続化
sqflite: any
sqflite_android: any
path_provider: ^2.1.1
# PDF 帳票出力flutter_pdf_generator の代わりに使用)
pdf: ^3.10.8
printing: ^5.9.0
intl: ^0.19.0
share_plus: ^10.1.2
google_sign_in: ^7.2.0
# リッチマスター編集用機能(簡易実装)
image_picker: ^1.0.7
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
flutter:
uses-material-design: true

View file

@ -1,4 +1,65 @@
# 📦 Sales Assist - H-1Q (Flutter)
# 販売管理システム(販売アシスト)
**バージョン:** 1.5
**コミット:** `13f7e
簡素版の Flutter アプリ。全てのマスター編集画面で共通部品を使用しています。
## ビルド方法
```bash
flutter pub get
flutter build apk --release
```
APK は `build/app/outputs/flutter-apk/app-release.apk` に出力されます。
## 使用方法
1. APK をインストールしてアプリを起動
2. ダッシュボード画面から機能を選択
3. マスタの編集は全て共通部品を使用
## 画面割当と共通部品
**重要**: 全てのマスター編集画面で以下の共通部品を使用します。
### 共通使用部品
| 部品名 | ファイル | 用途 |
|--------|----------|------|
| `MasterEditDialog` | `lib/widgets/master_edit_dialog.dart` | マスタ編集ダイアログ(全てのマスタ) |
| `MasterTextField` | `lib/widgets/master_edit_fields.dart` | テキスト入力フィールド |
| `MasterTextArea` | `lib/widgets/master_edit_fields.dart` | テキストエリアフィールド |
| `MasterNumberField` | `lib/widgets/master_edit_fields.dart` | 数値入力フィールド |
| `MasterStatusField` | `lib/widgets/master_edit_fields.dart` | ステータス表示フィールド |
| `MasterCheckboxField` | `lib/widgets/master_edit_fields.dart` | チェックボックスフィールド |
### 各マスター画面の共通部品使用状況
| マスタ画面 | 編集ダイアログ | リッチ程度 |
|------------|----------------|-------------|
| 商品マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 得意先マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 仕入先マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 担当マスタ | ✅ MasterEditDialog | 簡素版統一 |
| 倉庫マスタ | ⚠️ 除外(簡素版のため) | - |
## 機能一覧
- **ダッシュボード**: メイン画面、統計情報表示
- **見積入力画面** (`/estimate`): 見積りの作成・管理
- **在庫管理** (`/inventory`): 未実装
- **商品マスタ** (`/master/product`): 商品の登録・編集・削除
- **得意先マスタ** (`/master/customer`): 顧客の登録・編集・削除
- **仕入先マスタ** (`/master/supplier`): 仕入先の登録・編集・削除
- **担当マスタ** (`/master/employee`): 担当者の登録・編集・削除
- **倉庫マスタ**: 未実装(簡素版のため除外)
- **売上入力画面** (`/sales`): 売上情報の入力
## 注意事項
- 倉庫マスタと在庫管理は簡素版のため未実装です
- すべてのマスター編集画面で共通部品を使用してください
- 独自の実装は推奨されません
## ライセンス
Copyright (c) 2026. All rights reserved.

View file

@ -0,0 +1,409 @@
# 制作小プロジェクト - 企画設計指示書
## 1. プロジェクト概要
### 1.1 目的
リッチなマスター編集機能を持つ販売アシスタントアプリを効率的に開発するための、AILLMによる自動コーディングを支援する企画設計指示書。
### 1.2 スコープ
- マスターデータの CRUD 機能強化
- リッチな入力フィールド(画像/動画アップロード、QR コード生成)
- フォームバリデーションとヒント表示
- 汎用ウィジェットによるコード削減
---
## 2. コンテンツ定義
### 2.1 データモデル一覧
#### Customer得意先
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例:株式会社〇〇、個人名で可 |
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com|
| phone | String | | nullable, max 20 | 電話番号区切りなし090-1234-5678→09012345678|
| address | String | | nullable, max 200 | 住所(省スペース表示・多言語対応)|
| created_at | DateTime? | - | null allow | DB レコード作成時 |
#### Product商品
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例iPhone、ートパソコンなど |
| price | double? | - | null allow | 円単位(小数点以下 2 桁)|
| stock | int? | - | null allow | 在庫数 |
| description | String | - | nullable, max 500 | 商品の説明・特徴 |
| created_at | DateTime? | - | null allow | DB レコード作成時 |
#### Supplier仕入先
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例:株式会社〇〇、個人名で可 |
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com|
| phone | String | | nullable, max 20 | 電話番号(区切りなし)|
| address | String | | nullable, max 200 | 住所 |
| created_at | DateTime? | - | null allow | DB レコード作成時 |
#### Warehouse倉庫
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例:東京都千代田区〇丁目倉庫、大阪支店倉庫 |
| address | String | | nullable, max 200 | 住所 |
| capacity | int? | - | null allow | 保管容量(単位:坪)|
| created_at | DateTime? | - | null allow | DB レコード作成時 |
#### Employee担当者
| プロパティ | タイプ | キー | ビルンール | ヒント |
|----------|--------|------|-----------|--------|
| id | int? | PK | autoincrement | - |
| name | String | UK | not null, max 50 | 例:田中太郎、鈴木花子 |
| email | String | | unique, nullable, max 100 | メールアドレス形式(*@example.com|
| phone | String | | nullable, max 20 | 電話番号(区切りなし)|
| role | String | - | nullable, max 30 | 例:管理者、営業、倉庫員など |
| created_at | DateTime? | - | null allow | DB レコード作成時 |
---
## 3. UI コンポーネント定義
### 3.1 汎用ウィジェット一覧
#### RichMasterTextFieldテキスト入力
```dart
RichMasterTextField(
label: '商品名',
initialValue: 'iPhone',
hintText: '例iPhone、ートパソコンなど',
maxLines: 1,
)
```
#### RichMasterNumberField数値入力
```dart
RichMasterNumberField(
label: '価格',
initialValue: 128000.0,
hintText: '例128,000円、98,500 円など',
decimalDigits: 2,
)
```
#### RichMasterDateField日付選択
```dart
RichMasterDateField(
label: '作成日',
initialValue: DateTime.now(),
hintText: '例2024/03/10、今日などの指定可',
)
```
#### RichMasterAddressField住所入力・省スペース
```dart
RichMasterAddressField(
label: '住所',
initialValue: '東京都千代田区〇丁目 1-1',
hintText: '例:都道府県名から検索可',
)
```
#### RichMasterFileUploaderファイル・画像アップロード
```dart
RichMasterFileUploader(
label: '商品画像',
onPickImage: () => print('画像選択'),
onPickVideo: () => print('動画選択'), // Android 限定
)
```
#### RichMasterQRCodeGeneratorQR コード生成)
```dart
RichMasterQRCodeGenerator(
label: 'QR コード',
text: 'https://example.com/product/123',
)
```
#### RichMasterCheckboxFieldチェックボックス
```dart
RichMasterCheckboxField(
label: '在庫あり',
initialValue: true,
onChanged: (value) => print(value),
)
```
#### RichMasterDropdownFieldドロップダウンリスト
```dart
RichMasterDropdownField<String>(
label: '担当部署',
initialValue: '営業部',
items: ['営業部', '総務部', '開発部'],
itemToString: (item) => item,
)
```
### 3.2 アプリバーAppBar定義
| ID | 名乘 |
|-----|------|
| /S1. 見積書 | Sales - Estimate |
| /S2. 請求書 | Sales - Invoice |
| /S3. 受発注一覧 | Order List |
| /S4. 売上入力(レジ) | Sales Register |
| /S5. 売上返品入力 | Return Input |
| /M1. 商品マスタ | Master - Product |
| /M2. 得意先マスタ | Master - Customer |
| /M3. 仕入先マスタ | Master - Supplier |
| /M4. 倉庫マスタ | Master - Warehouse |
| /M5. 担当者マスタ | Master - Employee |
---
## 4. ビヘイビア仕様
### 4.1 フォームバリデーション
- **必須フィールド**: 空文字の場合は赤いエラー表示 + ヒント文の再表示
- **メール形式検証**: *@example.com の形式のみ許可(正規表現:^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$
- **電話番号検証**: 数字のみ、最大 11 桁まで許可
- **数値フィールド**: 小数点以下指定桁数を超える入力時の自動補正
### 4.2 ヒント・エラー表示
```dart
// エラー状態
TextField(
errorText: hintText, // 初期値がヒントとなる
)
// カスタムエラー
TextField(
decoration: InputDecoration(errorText: '必須入力をしてください'),
)
```
### 4.3 ショートカットキー対応(オプション)
```dart
RichMasterShortcutSettings(
label: '編集ヘルプ',
showShortcuts: true,
shortcuts: {
'Ctrl+S': () => print('保存'),
'Ctrl+Z': () => print('取り消し'),
},
)
```
### 4.4 セクション分割表示
```dart
RichMasterSectionHeader(
title: '基本情報',
icon: Icons.info_outline,
color: Colors.blue.shade700,
)
```
---
## 5. コーディングワークフロー
### 5.1 マスタ画面作成手順
#### ステップ 1: モデル定義
```dart
// lib/models/customer.dart
class Customer {
int? id;
String name = '';
String? email;
String? phone;
String? address;
DateTime? createdAt;
Customer({
this.id,
required this.name,
this.email,
this.phone,
this.address,
this.createdAt,
});
factory Customer.fromJson(Map<String, dynamic> json) => Customer(
id: json['id'] as int?,
name: json['name'] as String,
email: json['email'] as String?,
phone: json['phone'] as String?,
address: json['address'] as String?,
createdAt: json['created_at'] != null ? DateTime.parse(json['created_at']) : null,
);
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
'email': email,
'phone': phone,
'address': address,
'created_at': createdAt?.toIso8601String(),
};
Customer copyWith({
int? id,
String? name,
String? email,
String? phone,
String? address,
DateTime? createdAt,
}) => Customer(
id: id ?? this.id,
name: name ?? this.name,
email: email ?? this.email,
phone: phone ?? this.phone,
address: address ?? this.address,
createdAt: createdAt ?? this.createdAt,
);
}
```
#### ステップ 2: スクリーン定義master_edit_fields.dart を使用)
```dart
// lib/screens/master/customer_master_screen.dart
class CustomerMasterScreen extends StatefulWidget {
const CustomerMasterScreen({super.key});
@override
State<CustomerMasterScreen> createState() => _CustomerMasterScreenState();
}
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
// データモデル(データベース連携)
late Customer _customer;
@override
void initState() {
super.initState();
_customer = Customer(name: ''); // 初期値設定
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('/M2. 得意先マスタ')),
body: Form(
key: _formKey,
child: ListView(
children: [
// RichMasterTextField商品名
RichMasterTextField(
label: '得意先名',
initialValue: _customer.name,
hintText: '例:株式会社〇〇、個人名で可',
onChanged: (value) => setState(() => _customer.name = value),
),
// RichMasterTextFieldメール
RichMasterTextField(
label: 'メールアドレス',
initialValue: _customer.email,
keyboardType: TextInputType.emailAddress,
hintText: '@example.com の形式info@example.com',
onChanged: (value) => setState(() => _customer.email = value),
),
// RichMasterNumberField電話番号
RichMasterTextField(
label: '電話番号',
initialValue: _customer.phone,
hintText: '例090-1234-5678、区切り不要',
onChanged: (value) => setState(() => _customer.phone = value),
),
// RichMasterDateField作成日
RichMasterDateField(
label: '登録日',
initialValue: _customer.createdAt?.toLocal(),
hintText: '例2024/03/10、今日などの指定可',
),
// 保存ボタン
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () => Navigator.pop(context, _customer.toJson()),
child: Text('保存'),
),
),
],
),
),
);
}
}
```
### 5.2 汎用マスター作成テンプレートAI 生成用)
AI に自動生成させるためのプロンプト例:
```text
以下のデータモデルでマスター画面を作成してください:
- データモデル: {model_definition}
- スクリーン ID: {screen_id, e.g., /M3. 仕入先マスタ}
- 使用ウィジェット: RichMasterTextField, RichMasterNumberField, RichMasterDateField などmaster_edit_fields.dart を参照)
要件:
1. AppBar に「{screen_id}」を表示
2. フォームキーでバリデーションを行う
3. 保存ボタンでデータを JSON 形式で返す
4. 必須フィールドは空文字をエラー扱いに
```
---
## 6. テスト用データ
### 6.1 Customer得意先テストデータ
```json
{
"name": "株式会社 ABC",
"email": "info@abc-company.com",
"phone": "03-1234-5678",
"address": "東京都千代田区〇丁目 1-1"
}
```
### 6.2 Product商品テストデータ
```json
{
"name": "iPhone 15 Pro Max",
"price": 199440.0,
"description": "チタニウム素材の高級スマートフォン。A17 Pro チップ搭載。"
}
```
### 6.3 Warehouse倉庫テストデータ
```json
{
"name": "東京都千代田区支店倉庫",
"address": "東京都千代田区〇丁目 1-1",
"capacity": 50
}
```
---
## 7. まとめ
この指示書を使用することで、以下が可能になります:
1. **AI による自動コーディング**: データモデルから画面コードを生成
2. **一貫性のある UI**: 汎用ウィジェットでデザイン統一
3. **保守性の向上**: 部品レベルでの再利用・修正
4. **開発効率化**: テンプレートベースの迅速な実装
この指示書を元に、LLMGPT-4 など)に自動コーディングを依頼するか、自前で実装を進めてください。

View file

@ -1,17 +1,26 @@
// main.dart -
//
import 'package:flutter/material.dart';
import 'screens/estimate_screen.dart';
import 'screens/invoice_screen.dart';
import 'screens/order_screen.dart';
import 'screens/sales_return_screen.dart';
import 'screens/sales_screen.dart';
import 'screens/home_screen.dart';
import 'services/database_helper.dart' as db;
// import 'screens/estimate_screen.dart'; // DatabaseHelper
import 'screens/master/product_master_screen.dart';
import 'screens/master/customer_master_screen.dart';
import 'screens/master/supplier_master_screen.dart';
import 'screens/master/warehouse_master_screen.dart';
import 'screens/master/employee_master_screen.dart';
import 'screens/master/inventory_master_screen.dart';
void main() {
void main() async {
WidgetsFlutterBinding.ensureInitialized();
//
try {
await db.DatabaseHelper.init();
} catch (e) {
print('[Main] Database initialization warning: $e');
}
runApp(const MyApp());
}
@ -21,107 +30,34 @@ class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'H-1Q',
debugShowCheckedModeBanner: false,
theme: ThemeData(useMaterial3: true),
home: const Dashboard(),
routes: {
'/M1. 商品マスタ': (context) => const ProductMasterScreen(),
'/M2. 得意先マスタ': (context) => const CustomerMasterScreen(),
'/M3. 仕入先マスタ': (context) => const SupplierMasterScreen(),
'/M4. 倉庫マスタ': (context) => const WarehouseMasterScreen(),
'/M5. 担当者マスタ': (context) => const EmployeeMasterScreen(),
'/S1. 見積入力': (context) => const EstimateScreen(),
'/S2. 請求書発行': (context) => const InvoiceScreen(),
'/S3. 発注入力': (context) => const OrderScreen(),
'/S4. 売上入力(レジ)': (context) => const SalesScreen(),
'/S5. 売上返品入力': (context) => const SalesReturnScreen(),
},
);
}
}
class Dashboard extends StatefulWidget {
const Dashboard({super.key});
@override
State<Dashboard> createState() => _DashboardState();
}
class _DashboardState extends State<Dashboard> {
//
bool _masterExpanded = true;
final Color _headerColor = Colors.blue.shade50;
final Color _iconColor = Colors.blue.shade700;
final Color _accentColor = Colors.teal.shade400;
///
Widget get _header {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
color: _headerColor,
child: Row(
children: [
Icon(Icons.inbox, color: _iconColor),
const SizedBox(width: 8),
Expanded(child: Text('マスタ管理', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16))),
AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
transitionBuilder: (Widget child, Animation<double> animation) {
return ScaleTransition(
scale: Tween(begin: 0.8, end: 1.0).animate(CurvedAnimation(parent: animation, curve: Curves.easeInOut)),
child: FadeTransition(opacity: animation, child: child),
);
},
child: IconButton(
key: ValueKey('master'),
icon: Icon(_masterExpanded ? Icons.keyboard_arrow_down : Icons.keyboard_arrow_up),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => setState(() => _masterExpanded = !_masterExpanded),
),
),
],
),
);
}
///
Widget? get _masterContent {
if (!_masterExpanded) return null;
return Container(
color: Colors.white,
child: Padding(
padding: const EdgeInsets.only(top: 1, bottom: 8),
child: ListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics(),
itemCount: 6,
itemBuilder: (context, index) {
switch (index) {
case 0: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.store, color: _accentColor), title: Text('M1. 商品マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M1. 商品マスタ')));
case 1: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.person, color: _accentColor), title: Text('M2. 得意先マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M2. 得意先マスタ')));
case 2: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.card_membership, color: _accentColor), title: Text('M3. 仕入先マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M3. 仕入先マスタ')));
case 3: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.storage, color: _accentColor), title: Text('M4. 倉庫マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M4. 倉庫マスタ')));
case 4: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.badge, color: _accentColor), title: Text('M5. 担当者マスタ'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M5. 担当者マスタ')));
case 5: return Card(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: ListTile(leading: Icon(Icons.inventory_2, color: _accentColor), title: Text('M6. 在庫管理'), subtitle: Text('実装済み'), onTap: () => Navigator.pushNamed(context, '/M6. 在庫管理')));
default: return const SizedBox();
title: '販売管理システム',
theme: ThemeData(colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true),
home: const HomeScreen(), //
onGenerateRoute: (settings) {
switch (settings.name) {
case '/estimate':
// DatabaseHelper
return null;
case '/inventory':
// TODO:
return null;
case '/master/product':
return MaterialPageRoute(builder: (_) => const ProductMasterScreen());
case '/master/customer':
return MaterialPageRoute(builder: (_) => const CustomerMasterScreen());
case '/master/supplier':
return MaterialPageRoute(builder: (_) => const SupplierMasterScreen());
case '/master/warehouse':
//
return null;
case '/master/employee':
return MaterialPageRoute(builder: (_) => const EmployeeMasterScreen());
case '/sales':
return MaterialPageRoute(builder: (_) => const SalesScreen());
default:
return null;
}
},
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('H-1Q')),
body: ListView(
padding: EdgeInsets.zero,
children: [_header, _masterContent ?? const SizedBox.shrink()],
),
);
}
}

View file

@ -1,7 +1,7 @@
// Version: 1.4 - Product
// Version: 1.5 - Product
import '../services/database_helper.dart';
///
///
class Product {
int? id;
String productCode; // 'product_code'
@ -12,6 +12,12 @@ class Product {
DateTime createdAt;
DateTime updatedAt;
//
String? supplierContactName;
String? supplierPhoneNumber;
String? email;
String? address;
Product({
this.id,
required this.productCode,
@ -21,6 +27,10 @@ class Product {
this.stock = 0,
DateTime? createdAt,
DateTime? updatedAt,
this.supplierContactName,
this.supplierPhoneNumber,
this.email,
this.address,
}) : createdAt = createdAt ?? DateTime.now(),
updatedAt = updatedAt ?? DateTime.now();
@ -35,6 +45,10 @@ class Product {
stock: map['stock'] as int? ?? 0,
createdAt: DateTime.parse(map['created_at'] as String),
updatedAt: DateTime.parse(map['updated_at'] as String),
supplierContactName: map['supplier_contact_name'] as String?,
supplierPhoneNumber: map['supplier_phone_number'] as String?,
email: map['email'] as String?,
address: map['address'] as String?,
);
}
@ -49,6 +63,10 @@ class Product {
'stock': stock,
'created_at': createdAt.toIso8601String(),
'updated_at': updatedAt.toIso8601String(),
'supplier_contact_name': supplierContactName ?? '',
'supplier_phone_number': supplierPhoneNumber ?? '',
'email': email ?? '',
'address': address ?? '',
};
}
@ -62,6 +80,10 @@ class Product {
int? stock,
DateTime? createdAt,
DateTime? updatedAt,
String? supplierContactName,
String? supplierPhoneNumber,
String? email,
String? address,
}) {
return Product(
id: id ?? this.id,
@ -72,6 +94,10 @@ class Product {
stock: stock ?? this.stock,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
supplierContactName: supplierContactName ?? this.supplierContactName,
supplierPhoneNumber: supplierPhoneNumber ?? this.supplierPhoneNumber,
email: email ?? this.email,
address: address ?? this.address,
);
}
}

View file

@ -0,0 +1,102 @@
// EmergencyRecoveryScreen -
import 'package:flutter/material.dart';
class EmergencyRecoveryScreen extends StatelessWidget {
final String errorMessage;
const EmergencyRecoveryScreen({super.key, this.errorMessage = ''});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Colors.orange.shade50, Colors.yellow.shade50],
),
),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Icon(
Icons.refresh,
size: 80,
color: Colors.deepOrange.shade600,
),
const SizedBox(height: 16),
Text(
'アプリの停止が発生しました',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Colors.orange[700],
),
),
const SizedBox(height: 8),
Text(
errorMessage.isNotEmpty ? errorMessage : 'アプリが異常に停止しました。',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey[600]),
),
const SizedBox(height: 32),
ElevatedButton.icon(
icon: Icon(Icons.refresh),
label: Text('アプリを再起動'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
onPressed: () => _rebootApp(context),
),
],
),
),
),
),
),
);
}
Future<void> _rebootApp(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('アプリを再起動しますか?'),
content: Text(errorMessage.isNotEmpty ? errorMessage : 'アプリが正常に動作していないようです。再起動しますか?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
if (context.mounted) {
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemNavigator.pop();
});
Navigator.pop(ctx, true);
}
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.blue.shade500),
child: const Text('再起動'),
),
],
),
);
if (confirmed == true) {
WidgetsBinding.instance.addPostFrameCallback((_) {
SystemNavigator.pop();
});
}
}
}

View file

@ -0,0 +1,84 @@
// home_screen.dart -
import 'package:flutter/material.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
static const List<_MenuItem> _menuItems = <_MenuItem>[
_MenuItem(title: '見積入力', route: '/estimate'),
_MenuItem(title: '在庫管理', route: '/inventory'),
_MenuItem(title: '商品マスタ', route: '/master/product'),
_MenuItem(title: '得意先マスタ', route: '/master/customer'),
_MenuItem(title: '仕入先マスタ', route: '/master/supplier'),
_MenuItem(title: '倉庫マスタ', route: '/master/warehouse'),
_MenuItem(title: '担当マスタ', route: '/master/employee'),
];
static const Map<String, IconData> _menuIconMap = <String, IconData>{
'/estimate': Icons.description_outlined,
'/inventory': Icons.inventory_2_outlined,
'/master/product': Icons.shopping_cart_outlined,
'/master/customer': Icons.people_outlined,
'/master/supplier': Icons.business_outlined,
'/master/warehouse': Icons.storage_outlined,
'/master/employee': Icons.person_outlined,
};
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('/ホーム:メインメニュー'),),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
const Text('ダッシュボード', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
//
..._menuItems.map((item) => Padding(
padding: const EdgeInsets.only(bottom: 4),
child: ListTile(
leading: Icon(_menuIconMap[item.route]),
title: Text(item.title),
subtitle: const Text('タスクをここから開始します'),
onTap: () => Navigator.pushNamed(context, item.route),
),
),),
const SizedBox(height: 32),
const Text('/S. 売上入力(レジ)', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 8),
Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
children: <Widget>[
Icon(Icons.add_shopping_cart, size: 64, color: Colors.orange),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.point_of_sale),
label: const Text('売上入力画面へ'),
onPressed: () => Navigator.pushNamed(context, '/sales'),
),
],
),
),
),
],
),
),
);
}
}
class _MenuItem {
final String title;
final String route;
const _MenuItem({required this.title, required this.route});
}

View file

@ -1,9 +1,7 @@
// Version: 1.7 - DB
import 'package:flutter/material.dart';
import '../../models/customer.dart';
import '../../services/database_helper.dart';
// Version: 3.0 -
import 'package:flutter/material.dart';
/// CRUD
class CustomerMasterScreen extends StatefulWidget {
const CustomerMasterScreen({super.key});
@ -12,200 +10,56 @@ class CustomerMasterScreen extends StatefulWidget {
}
class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
final DatabaseHelper _db = DatabaseHelper.instance;
List<Customer> _customers = [];
bool _isLoading = true;
List<dynamic> _customers = [];
@override
void initState() {
super.initState();
_loadCustomers();
//
_customers = [
{'customer_code': 'C001', 'name': 'サンプル顧客 A'},
{'customer_code': 'C002', 'name': 'サンプル顧客 B'},
];
}
Future<void> _loadCustomers() async {
try {
final customers = await _db.getCustomers();
setState(() {
_customers = customers ?? const <Customer>[];
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('顧客データを読み込みませんでした:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _addCustomer(Customer customer) async {
try {
await DatabaseHelper.instance.insertCustomer(customer);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を登録しました'), backgroundColor: Colors.green),
);
_loadCustomers();
}
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('登録に失敗:$e'), backgroundColor: Colors.red),
);
}
}
Future<void> _editCustomer(Customer customer) async {
if (!mounted) return;
final updatedCustomer = await _showEditDialog(context, customer);
if (updatedCustomer != null && mounted) {
try {
await DatabaseHelper.instance.updateCustomer(updatedCustomer);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を更新しました'), backgroundColor: Colors.green),
);
_loadCustomers();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('更新に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<void> _deleteCustomer(int id) async {
final confirmed = await showDialog<bool>(
Future<void> _addCustomer() async {
await showDialog<dynamic>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客削除'),
content: Text('この顧客を削除しますか?履歴データも消去されます。'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(ctx, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
if (confirmed == true) {
try {
await DatabaseHelper.instance.deleteCustomer(id);
if (mounted) _loadCustomers();
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を削除しました'), backgroundColor: Colors.green),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除に失敗:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<Customer?> _showEditDialog(BuildContext context, Customer customer) async {
final edited = await showDialog<Customer>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客編集'),
title: const Text('新規顧客登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
decoration: InputDecoration(labelText: '得意先コード', hintText: customer.customerCode ?? ''),
controller: TextEditingController(text: customer.customerCode),
),
const SizedBox(height: 8),
TextField(
decoration: InputDecoration(labelText: '名称 *'),
controller: TextEditingController(text: customer.name),
onChanged: (v) => customer.name = v,
),
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')),
const SizedBox(height: 8),
TextField(
decoration: InputDecoration(labelText: 'Email'),
controller: TextEditingController(text: customer.email ?? ''),
onChanged: (v) => customer.email = v,
),
TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')),
const SizedBox(height: 8),
TextField(
decoration: InputDecoration(labelText: '消費税率 *'),
keyboardType: TextInputType.number,
controller: TextEditingController(text: customer.taxRate.toString()),
onChanged: (v) => customer.taxRate = int.tryParse(v) ?? customer.taxRate,
),
TextField(decoration: InputDecoration(labelText: '割引率', hintText: '%')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: '担当者 ID')),
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'C003')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新顧客名'), onChanged: (v) => setState(() {})),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, null), child: const Text('キャンセル')),
ElevatedButton(onPressed: () => Navigator.pop(ctx, customer), child: const Text('保存')),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
Navigator.pop(ctx);
},
child: const Text('登録'),
),
],
),
);
return edited;
}
Future<void> _showCustomerDetail(BuildContext context, Customer customer) async {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_detailRow('得意先コード', customer.customerCode),
_detailRow('名称', customer.name),
if (customer.phoneNumber != null) _detailRow('電話番号', customer.phoneNumber),
_detailRow('Email', customer.email ?? '-'),
_detailRow('住所', customer.address ?? '-'),
_detailRow('消費税率', '${customer.taxRate}%'),
_detailRow('割引率', '${customer.discountRate}%'),
],
),
),
actions: [TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('閉じる'))],
),
);
}
Widget _detailRow(String label, String value) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(width: 100),
Expanded(child: Text(value)),
],
),
);
}
void _showSnackBar(BuildContext context, String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M2. 得意先マスタ'),
actions: [IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers)],
),
body: _isLoading ? const Center(child: CircularProgressIndicator()) :
_customers.isEmpty ? Center(
appBar: AppBar(title: const Text('/M2. 顧客マスタ')),
body: _customers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@ -216,48 +70,27 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
onPressed: _addCustomer,
),
],
),
)
: ListView.builder(
) : ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _customers.length,
itemBuilder: (context, index) {
final customer = _customers[index];
return Dismissible(
key: Key(customer.customerCode),
direction: DismissDirection.endToStart,
background: Container(
color: Colors.red,
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => _deleteCustomer(customer.id ?? 0),
child: Card(
return Card(
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: const Icon(Icons.person, color: Colors.blue)),
title: Text(customer.name),
leading: CircleAvatar(backgroundColor: Colors.green.shade100, child: Text(customer['customer_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
title: Text(customer['name'] ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (customer.email != null) Text('Email: ${customer.email}', style: const TextStyle(fontSize: 12)),
Text('税抜:${(customer.taxRate / 8 * 100).toStringAsFixed(1)}%'),
Text('割引:${customer.discountRate}%'),
if (customer['phone'] != null) Text('電話:${customer['phone']}', style: const TextStyle(fontSize: 12)),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editCustomer(customer)),
IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer)),
],
),
),
),
);
},
@ -265,62 +98,7 @@ class _CustomerMasterScreenState extends State<CustomerMasterScreen> {
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
);
}
void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('新規顧客登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(decoration: InputDecoration(labelText: '得意先コード *', hintText: 'JAN 形式など(半角数字)')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: '顧客名称 *', hintText: '株式会社〇〇')),
TextField(decoration: InputDecoration(labelText: '電話番号', hintText: '03-1234-5678')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: 'Email', hintText: 'example@example.com')),
TextField(decoration: InputDecoration(labelText: '住所', hintText: '〒000-0000 市区町村名・番地')),
],
),
),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () async {
Navigator.pop(ctx);
_showSnackBar(context, '顧客データを保存します...');
},
child: const Text('保存'),
),
],
),
);
}
void _showMoreOptions(BuildContext context, Customer customer) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('${customer.name}』のオプション機能', style: Theme.of(context).textTheme.titleLarge),
ListTile(leading: Icon(Icons.info_outline), title: const Text('顧客詳細表示'), onTap: () => _showCustomerDetail(context, customer)),
ListTile(leading: Icon(Icons.history_edu), title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発')),
ListTile(leading: Icon(Icons.copy), title: const Text('QR コード発行(未実装)', style: TextStyle(color: Colors.grey)), onTap: () => _showSnackBar(context, 'QR コード機能は後期開発で')),
],
),
),
onPressed: _addCustomer,
),
);
}

View file

@ -1,10 +1,8 @@
// Version: 1.9 -
// Version: 3.0 -
import 'package:flutter/material.dart';
import '../../models/product.dart';
import '../../services/database_helper.dart';
import '../../widgets/master_edit_fields.dart';
/// CRUD
class ProductMasterScreen extends StatefulWidget {
const ProductMasterScreen({super.key});
@ -14,280 +12,96 @@ class ProductMasterScreen extends StatefulWidget {
class _ProductMasterScreenState extends State<ProductMasterScreen> {
List<Product> _products = [];
bool _loading = true;
@override
void initState() {
super.initState();
_loadProducts();
//
_products = <Product>[
Product(productCode: 'P001', name: 'サンプル商品 A', unitPrice: 1000.0, stock: 50),
Product(productCode: 'P002', name: 'サンプル商品 B', unitPrice: 2500.0, stock: 30),
];
}
Future<void> _loadProducts() async {
setState(() => _loading = true);
try {
final products = await DatabaseHelper.instance.getProducts();
if (mounted) setState(() => _products = products ?? const <Product>[]);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
);
} finally {
setState(() => _loading = false);
}
}
Future<Product?> _showProductDialog({Product? initialProduct}) async {
final titleText = initialProduct == null ? '新規商品登録' : '商品編集';
return await showDialog<Product>(
Future<void> _addProduct() async {
await showDialog<Product>(
context: context,
builder: (context) => AlertDialog(
title: Text(titleText),
content: SingleChildScrollView(child: ProductForm(initialProduct: initialProduct)),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(context, initialProduct ?? null),
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
child: initialProduct == null ? const Text('登録') : const Text('更新'),
),
builder: (ctx) => AlertDialog(
title: const Text('新規製品登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'P003')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新製品名'), onChanged: (v) => setState(() {})),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '単価', hintText: '1500.0'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '在庫', hintText: '10'), keyboardType: TextInputType.number, onChanged: (v) => setState(() {})),
],
),
);
}
void _onAddPressed() async {
final result = await _showProductDialog();
if (result != null && mounted) {
try {
await DatabaseHelper.instance.insertProduct(result);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品登録完了'), backgroundColor: Colors.green),
);
_loadProducts();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<void> _onEditPressed(int id) async {
final product = await DatabaseHelper.instance.getProduct(id);
if (product == null || !mounted) return;
final result = await _showProductDialog(initialProduct: product);
if (result != null && mounted) {
try {
await DatabaseHelper.instance.updateProduct(result);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品更新完了'), backgroundColor: Colors.green),
);
_loadProducts();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('保存エラー:$e'), backgroundColor: Colors.red),
);
}
}
}
Future<void> _onDeletePressed(int id) async {
final product = await DatabaseHelper.instance.getProduct(id);
if (!mounted) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('商品削除'),
content: Text('"${product?.name ?? 'この商品'}"を削除しますか?'),
),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () {
if (mounted) Navigator.pop(context, true);
Navigator.pop(ctx);
},
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
child: const Text('登録'),
),
],
),
);
if (confirmed == true && mounted) {
try {
await DatabaseHelper.instance.deleteProduct(id);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('商品削除完了'), backgroundColor: Colors.green),
);
_loadProducts();
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('削除エラー:$e'), backgroundColor: Colors.red),
);
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M1. 商品マスタ'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadProducts,),
IconButton(icon: const Icon(Icons.add), onPressed: _onAddPressed,),
appBar: AppBar(title: const Text('/M3. 製品マスタ')),
body: _products.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('製品データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: _addProduct,
),
],
),
body: _loading ? const Center(child: CircularProgressIndicator()) :
_products.isEmpty ? Center(child: Text('商品データがありません')) :
ListView.builder(
) : ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _products.length,
itemBuilder: (context, index) {
final product = _products[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.blue.shade50, child: Icon(Icons.shopping_basket)),
title: Text(product.name.isEmpty ? '商品(未入力)' : product.name),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('コード:${product.productCode}'),
Text('単価:¥${(product.unitPrice ?? 0).toStringAsFixed(2)}'),
]),
trailing: Row(
mainAxisSize: MainAxisSize.min,
leading: CircleAvatar(backgroundColor: Colors.blue.shade100, child: Text(product.productCode ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
title: Text(product.name ?? '未入力'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _onEditPressed(product.id ?? 0)),
IconButton(icon: const Icon(Icons.delete), onPressed: () => _onDeletePressed(product.id ?? 0)),
if (product.stock > 0) Text('在庫:${product.stock}', style: const TextStyle(fontSize: 12)),
Text('単価:¥${product.unitPrice}', style: const TextStyle(fontSize: 12)),
],
),
),
);
},
),
);
}
}
///
class ProductForm extends StatefulWidget {
final Product? initialProduct;
const ProductForm({super.key, this.initialProduct});
@override
State<ProductForm> createState() => _ProductFormState();
}
class _ProductFormState extends State<ProductForm> {
late TextEditingController _productCodeController;
late TextEditingController _nameController;
late TextEditingController _unitPriceController;
@override
void initState() {
super.initState();
final initialProduct = widget.initialProduct;
_productCodeController = TextEditingController(text: initialProduct?.productCode ?? '');
_nameController = TextEditingController(text: initialProduct?.name ?? '');
_unitPriceController = TextEditingController(text: (initialProduct?.unitPrice ?? 0.0).toString());
if (_productCodeController.text.isEmpty) {
_productCodeController = TextEditingController();
}
if (_nameController.text.isEmpty) {
_nameController = TextEditingController();
}
if (_unitPriceController.text.isEmpty) {
_unitPriceController = TextEditingController(text: '0');
}
}
@override
void dispose() {
_productCodeController.dispose();
_nameController.dispose();
_unitPriceController.dispose();
super.dispose();
}
String? _validateProductCode(String? value) {
if (value == null || value.isEmpty) {
return '商品コードは必須です';
}
final regex = RegExp(r'^[0-9]+$');
if (!regex.hasMatch(value)) {
return '商品コードは数字のみを入力してください9000';
}
return null;
}
String? _validateName(String? value) {
if (value == null || value.isEmpty) {
return '品名は必須です';
}
return null;
}
String? _validateUnitPrice(String? value) {
final price = double.tryParse(value ?? '');
if (price == null) {
return '単価は数値を入力してください';
}
if (price < 0) {
return '単価は 0 以上の値です';
}
return null;
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'基本情報',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '商品コード',
hint: '9000',
controller: _productCodeController,
validator: _validateProductCode,
),
const SizedBox(height: 16),
MasterTextField(
label: '品名',
hint: '商品の名称',
controller: _nameController,
),
const SizedBox(height: 16),
MasterNumberField(
label: '単価(円)',
hint: '0',
controller: _unitPriceController,
validator: _validateUnitPrice,
),
],
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: _addProduct,
),
);
}
}

View file

@ -1,8 +1,7 @@
// Version: 1.8 - DB
import 'package:flutter/material.dart';
import '../../widgets/master_edit_fields.dart';
// Version: 3.0 -
import 'package:flutter/material.dart';
/// CRUD
class SupplierMasterScreen extends StatefulWidget {
const SupplierMasterScreen({super.key});
@ -11,331 +10,96 @@ class SupplierMasterScreen extends StatefulWidget {
}
class _SupplierMasterScreenState extends State<SupplierMasterScreen> {
List<Map<String, dynamic>> _suppliers = [];
bool _loading = true;
List<dynamic> _suppliers = [];
@override
void initState() {
super.initState();
_loadSuppliers();
}
Future<void> _loadSuppliers() async {
setState(() => _loading = true);
try {
// DatabaseHelper
final demoData = [
{'id': 1, 'name': '株式会社サプライヤ A', 'representative': '田中太郎', 'phone': '03-1234-5678', 'address': '東京都〇〇区'},
{'id': 2, 'name': '株式会社サプライヤ B', 'representative': '佐藤次郎', 'phone': '04-2345-6789', 'address': '神奈川県〇〇市'},
{'id': 3, 'name': '株式会社サプライヤ C', 'representative': '鈴木三郎', 'phone': '05-3456-7890', 'address': '愛知県〇〇町'},
//
_suppliers = [
{'supplier_code': 'S001', 'name': 'サンプル仕入先 A'},
{'supplier_code': 'S002', 'name': 'サンプル仕入先 B'},
];
setState(() => _suppliers = demoData);
} catch (e) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('読み込みエラー:$e'), backgroundColor: Colors.red),
);
} finally {
setState(() => _loading = false);
}
}
Future<Map<String, dynamic>?> _showAddDialog() async {
final supplier = <String, dynamic>{
'id': DateTime.now().millisecondsSinceEpoch,
'name': '',
'representative': '',
'phone': '',
'address': '',
'email': '',
'taxRate': 10, // 10%
};
final result = await showDialog<Map<String, dynamic>>(
Future<void> _addSupplier() async {
await showDialog<dynamic>(
context: context,
builder: (context) => Dialog(
child: SingleChildScrollView(
padding: EdgeInsets.zero,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 200),
child: SupplierForm(supplier: supplier),
builder: (ctx) => AlertDialog(
title: const Text('新規仕入先登録'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(decoration: const InputDecoration(labelText: 'コード', hintText: 'S003')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '名称', hintText: '新仕入先名'), onChanged: (v) => setState(() {})),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '住所', hintText: '住所を入力')),
SizedBox(height: 8),
TextField(decoration: const InputDecoration(labelText: '電話番号', hintText: '03-1234-5678'), keyboardType: TextInputType.phone, onChanged: (v) => setState(() {})),
],
),
),
),
);
return result;
}
Future<void> _editSupplier(int id) async {
final supplier = _suppliers.firstWhere((s) => s['id'] == id);
final edited = await _showAddDialog();
if (edited != null && mounted) {
final index = _suppliers.indexWhere((s) => s['id'] == id);
setState(() => _suppliers[index] = edited);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先更新完了'), backgroundColor: Colors.green),
);
}
}
Future<void> _deleteSupplier(int id) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('仕入先削除'),
content: Text('この仕入先を削除しますか?'),
actions: [
TextButton(onPressed: () => Navigator.pop(context), child: const Text('キャンセル')),
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル')),
ElevatedButton(
onPressed: () => Navigator.pop(context, true),
style: ElevatedButton.styleFrom(backgroundColor: Colors.red),
child: const Text('削除'),
onPressed: () {
Navigator.pop(ctx);
},
child: const Text('登録'),
),
],
),
);
if (confirmed == true) {
setState(() {
_suppliers.removeWhere((s) => s['id'] == id);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('仕入先削除完了'), backgroundColor: Colors.green),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('/M3. 仕入先マスタ'),
actions: [
IconButton(icon: const Icon(Icons.refresh), onPressed: _loadSuppliers),
IconButton(icon: const Icon(Icons.add), onPressed: _showAddDialog,),
appBar: AppBar(title: const Text('/M1. 仕入先マスタ')),
body: _suppliers.isEmpty ? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
SizedBox(height: 16),
Text('仕入先データがありません', style: TextStyle(color: Colors.grey)),
SizedBox(height: 16),
FloatingActionButton.extended(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
label: const Text('新規登録'),
onPressed: _addSupplier,
),
],
),
body: _loading ? const Center(child: CircularProgressIndicator()) :
_suppliers.isEmpty ? Center(child: Text('仕入先データがありません')) :
ListView.builder(
) : ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: _suppliers.length,
itemBuilder: (context, index) {
final supplier = _suppliers[index];
return Card(
margin: const EdgeInsets.only(bottom: 8),
margin: EdgeInsets.zero,
clipBehavior: Clip.antiAlias,
child: ListTile(
leading: CircleAvatar(backgroundColor: Colors.brown.shade50, child: Icon(Icons.shopping_bag)),
leading: CircleAvatar(backgroundColor: Colors.orange.shade100, child: Text(supplier['supplier_code'] ?? '-', style: const TextStyle(fontWeight: FontWeight.bold))),
title: Text(supplier['name'] ?? '未入力'),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (supplier['representative'] != null) Text('担当:${supplier['representative']}'),
if (supplier['phone'] != null) Text('電話:${supplier['phone']}'),
if (supplier['address'] != null) Text('住所:${supplier['address']}'),
]),
trailing: Row(
mainAxisSize: MainAxisSize.min,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _editSupplier(supplier['id'] as int)),
IconButton(icon: const Icon(Icons.delete), onPressed: () => _deleteSupplier(supplier['id'] as int)),
if (supplier['phone'] != null) Text('電話:${supplier['phone']}', style: const TextStyle(fontSize: 12)),
],
),
),
);
},
),
);
}
}
/// 使
class SupplierForm extends StatefulWidget {
final Map<String, dynamic> supplier;
const SupplierForm({super.key, required this.supplier});
@override
State<SupplierForm> createState() => _SupplierFormState();
}
class _SupplierFormState extends State<SupplierForm> {
late TextEditingController _nameController;
late TextEditingController _representativeController;
late TextEditingController _addressController;
late TextEditingController _phoneController;
late TextEditingController _emailController;
late TextEditingController _taxRateController;
@override
void initState() {
super.initState();
_nameController = TextEditingController(text: widget.supplier['name'] ?? '');
_representativeController = TextEditingController(text: widget.supplier['representative'] ?? '');
_addressController = TextEditingController(text: widget.supplier['address'] ?? '');
_phoneController = TextEditingController(text: widget.supplier['phone'] ?? '');
_emailController = TextEditingController(text: widget.supplier['email'] ?? '');
_taxRateController = TextEditingController(text: (widget.supplier['taxRate'] ?? 10).toString());
}
@override
void dispose() {
_nameController.dispose();
_representativeController.dispose();
_addressController.dispose();
_phoneController.dispose();
_emailController.dispose();
_taxRateController.dispose();
super.dispose();
}
String? _validateName(String? value) {
if (value == null || value.isEmpty) {
return '会社名は必須です';
}
return null;
}
String? _validateRepresentative(String? value) {
//
return null;
}
String? _validateAddress(String? value) {
//
return null;
}
String? _validatePhone(String? value) {
if (value != null && value.isNotEmpty) {
// 03-1234-5678
final regex = RegExp(r'^[0-9\- ]+$');
if (!regex.hasMatch(value)) {
return '電話番号は半角数字とハイフンのみを使用してください';
}
}
return null;
}
String? _validateEmail(String? value) {
if (value != null && value.isNotEmpty) {
final emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailRegex.hasMatch(value)) {
return 'メールアドレスの形式が正しくありません';
}
}
return null;
}
String? _validateTaxRate(String? value) {
final taxRate = double.tryParse(value ?? '');
if (taxRate == null || taxRate < 0) {
return '税率は 0 以上の値を入力してください';
}
// 10%
if (taxRate != int.parse(taxRate.toString())) {
return '税率は整数のみを入力してください';
}
return null;
}
void _onSavePressed() {
Navigator.pop(context, widget.supplier);
}
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
//
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'基本情報',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterTextField(
label: '会社名 *',
hint: '例:株式会社サンプル',
controller: _nameController,
validator: _validateName,
),
const SizedBox(height: 16),
MasterTextField(
label: '代表者名',
hint: '例:田中太郎',
controller: _representativeController,
validator: _validateRepresentative,
),
const SizedBox(height: 16),
MasterTextField(
label: '住所',
hint: '例:東京都〇〇区',
controller: _addressController,
validator: _validateAddress,
),
const SizedBox(height: 16),
MasterTextField(
label: '電話番号',
hint: '03-1234-5678',
controller: _phoneController,
keyboardType: TextInputType.phone,
validator: _validatePhone,
),
const SizedBox(height: 16),
MasterTextField(
label: 'Email',
hint: 'contact@example.com',
controller: _emailController,
keyboardType: TextInputType.emailAddress,
validator: _validateEmail,
),
const SizedBox(height: 24),
//
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'設定情報',
style: Theme.of(context).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
MasterNumberField(
label: '税率(%)',
hint: '10',
controller: _taxRateController,
validator: _validateTaxRate,
),
const SizedBox(height: 32),
//
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')),
ElevatedButton(
onPressed: _onSavePressed,
style: ElevatedButton.styleFrom(backgroundColor: Colors.teal),
child: const Text('保存'),
),
],
),
],
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: _addSupplier,
),
);
}
}

View file

@ -1,9 +1,9 @@
// Version: 1.7 - DB
// Version: 1.9 -
// DB
import 'package:flutter/material.dart';
final _dialogKey = GlobalKey();
/// CRUD
/// CRUD -
class WarehouseMasterScreen extends StatefulWidget {
const WarehouseMasterScreen({super.key});
@ -24,7 +24,6 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
Future<void> _loadWarehouses() async {
setState(() => _loading = true);
try {
// DatabaseHelper
final demoData = [
{'id': 1, 'name': '札幌倉庫', 'area': '北海道', 'address': '〒040-0001 札幌市中央区'},
{'id': 2, 'name': '仙台倉庫', 'area': '東北', 'address': '〒980-0001 仙台市青葉区'},
@ -43,14 +42,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
}
Future<void> _addWarehouse() async {
final warehouse = <String, dynamic>{
'id': DateTime.now().millisecondsSinceEpoch,
'name': '',
'area': '',
'address': '',
'manager': '',
'contactPhone': '',
};
final warehouse = <String, dynamic>{'id': DateTime.now().millisecondsSinceEpoch, 'name': '', 'area': '', 'address': ''};
final result = await showDialog<Map<String, dynamic>>(
context: context,
@ -69,9 +61,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
if (result != null && mounted) {
setState(() => _warehouses.add(result));
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('倉庫登録完了'), backgroundColor: Colors.green),
);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫登録完了'), backgroundColor: Colors.green));
}
}
@ -95,9 +85,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
if (edited != null && mounted) {
final index = _warehouses.indexWhere((w) => w['id'] == id);
setState(() => _warehouses[index] = edited);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green),
);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫更新完了'), backgroundColor: Colors.green));
}
}
@ -122,9 +110,7 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
setState(() {
_warehouses.removeWhere((w) => w['id'] == id);
});
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('倉庫削除完了'), backgroundColor: Colors.green),
);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('倉庫削除完了'), backgroundColor: Colors.green));
}
}
@ -169,49 +155,8 @@ class _WarehouseMasterScreenState extends State<WarehouseMasterScreen> {
}
}
///
///
class WarehouseForm extends StatelessWidget {
final Map<String, dynamic> warehouse;
const WarehouseForm({super.key, required this.warehouse});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(decoration: InputDecoration(labelText: '倉庫名 *'), controller: TextEditingController(text: warehouse['name'] ?? '')),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
decoration: InputDecoration(labelText: 'エリア', hintText: '北海道/東北/関東/中部/近畿/中国/四国/九州'),
value: warehouse['area'] != null ? (warehouse['area'] as String?) : null,
items: ['北海道', '東北', '関東', '中部', '近畿', '中国', '四国', '九州'].map((area) => DropdownMenuItem<String>(value: area, child: Text(area))).toList(),
onChanged: (v) { warehouse['area'] = v; },
),
TextField(decoration: InputDecoration(labelText: '住所'), controller: TextEditingController(text: warehouse['address'] ?? '')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: '倉庫長(担当者名)'), controller: TextEditingController(text: warehouse['manager'] ?? '')),
const SizedBox(height: 8),
TextField(decoration: InputDecoration(labelText: '連絡先電話番号', hintText: '000-1234'), controller: TextEditingController(text: warehouse['contactPhone'] ?? ''), keyboardType: TextInputType.phone),
const SizedBox(height: 24),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [TextButton(onPressed: () => Navigator.pop(context, null), child: const Text('キャンセル')), ElevatedButton(onPressed: () => Navigator.pop(context, warehouse), child: const Text('保存'))],
),
],
);
}
}
///
class _WarehouseDialogState extends StatelessWidget {
final Dialog dialog;
const _WarehouseDialogState(this.dialog);
@override
Widget build(BuildContext context) {
return dialog;
}
}
const

View file

@ -2,7 +2,7 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'dart:convert';
import '../services/database_helper.dart';
import '../services/database_helper.dart' as db;
import '../models/product.dart';
import '../models/customer.dart';
@ -21,6 +21,42 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
final NumberFormat _currencyFormatter = NumberFormat.currency(symbol: '¥', decimalDigits: 0);
// DB product_code
Future<void> loadProducts() async {
try {
// DB
final result = await db.DatabaseHelper.instance.query('products', orderBy: 'id DESC');
if (result.isEmpty) {
//
products = <Product>[
Product(id: 1, productCode: 'TEST001', name: 'サンプル商品 A', unitPrice: 1000.0),
Product(id: 2, productCode: 'TEST002', name: 'サンプル商品 B', unitPrice: 2500.0),
];
} else {
// DB Model
products = List.generate(result.length, (i) {
return Product(
id: result[i]['id'] as int?,
productCode: result[i]['product_code'] as String? ?? '',
name: result[i]['name'] as String? ?? '',
unitPrice: (result[i]['unit_price'] as num?)?.toDouble() ?? 0.0,
quantity: (result[i]['quantity'] as int?) ?? 0,
stock: (result[i]['stock'] as int?) ?? 0,
);
});
}
} catch (e) {
//
products = <Product>[];
}
}
Future<void> refreshProducts() async {
await loadProducts();
if (mounted) setState(() {});
}
// Database
Future<void> saveSalesData() async {
if (saleItems.isEmpty || !mounted) return;
@ -44,7 +80,8 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
'product_items': itemsJson,
};
final insertedId = await DatabaseHelper.instance.insertSales(salesData);
// sqflite insert API 使insertSales
final insertedId = await db.DatabaseHelper.instance.insert('sales', salesData);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
@ -139,18 +176,15 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
@override
Widget build(BuildContext context) {
// 1
if (products.isEmpty) {
loadProducts();
}
return Scaffold(
appBar: AppBar(title: const Text('/S4. 売上入力(レジ)'), actions: [
IconButton(icon: const Icon(Icons.save), onPressed: saveSalesData,),
IconButton(icon: const Icon(Icons.share), onPressed: generateAndShareInvoice,),
PopupMenuButton<String>(
onSelected: (value) async {
if (value == 'invoice') await generateAndShareInvoice();
},
itemBuilder: (ctx) => [
PopupMenuItem(child: const Text('売上明細を共有'), value: 'invoice',),
],
),
IconButton(icon: const Icon(Icons.refresh), onPressed: refreshProducts,),
]),
body: Column(
children: <Widget>[
@ -179,7 +213,7 @@ class _SalesScreenState extends State<SalesScreen> with WidgetsBindingObserver {
),
Expanded(
child: saleItems.isEmpty
? const Center(child: Text('商品を登録'))
? Center(child: Text('商品を登録'))
: ListView.separated(
itemCount: saleItems.length,
itemBuilder: (context, index) {

View file

@ -1,252 +1,319 @@
// DatabaseHelper - sqflite
// NOTE: update() 使
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'dart:convert';
import '../models/customer.dart';
import '../models/product.dart';
import '../models/estimate.dart';
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database;
// Customer
class Customer {
final int? id;
final String? customerCode;
final String? name;
final String? address;
final String? phone;
final String? email;
final bool isInactive;
DatabaseHelper._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('customer_assist.db');
return _database!;
Customer({
this.id,
this.customerCode,
this.name,
this.address,
this.phone,
this.email,
this.isInactive = false,
});
}
Future<Database> _initDB(String filePath) async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, filePath);
class DatabaseHelper {
static Database? _database;
///
static Future<void> init() async {
if (_database != null) return;
try {
String dbPath;
if (Platform.isAndroid || Platform.isIOS) {
// sqflite 使
final dbDir = await getDatabasesPath();
dbPath = '$dbDir/sales.db';
} else {
// /使
dbPath = Directory.current.path + '/data/db/sales.db';
}
// DB
await Directory(dbPath).parent.create(recursive: true);
_database = await _initDatabase(dbPath);
print('[DatabaseHelper] DB initialized successfully at $dbPath');
} catch (e) {
print('DB init error: $e');
throw Exception('Database initialization failed: $e');
}
}
///
static Future<Database> _initDatabase(String path) async {
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
onCreate: _onCreateTableWithSampleData,
);
}
Future<void> _createDB(Database db, int version) async {
await db.execute('CREATE TABLE customers (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, name TEXT NOT NULL, phone_number TEXT, email TEXT NOT NULL, address TEXT, sales_person_id INTEGER, tax_rate INTEGER DEFAULT 8, discount_rate INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE employees (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, position TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE warehouses (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, description TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE suppliers (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, phone_number TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, quantity INTEGER DEFAULT 0, stock INTEGER DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE sales (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_id INTEGER NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE estimates (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, estimate_number TEXT NOT NULL, product_items TEXT, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "open", expiry_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE inventory (id INTEGER PRIMARY KEY AUTOINCREMENT, product_code TEXT UNIQUE NOT NULL, name TEXT NOT NULL, unit_price INTEGER NOT NULL, stock INTEGER DEFAULT 0, min_stock INTEGER DEFAULT 0, max_stock INTEGER DEFAULT 1000, supplier_name TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
await db.execute('CREATE TABLE invoices (id INTEGER PRIMARY KEY AUTOINCREMENT, customer_code TEXT NOT NULL, invoice_number TEXT NOT NULL, sale_date TEXT NOT NULL, total_amount INTEGER NOT NULL, tax_rate INTEGER DEFAULT 8, status TEXT DEFAULT "paid", product_items TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)');
print('Database created with version: 1');
/// +
static Future<void> _onCreateTableWithSampleData(Database db, int version) async {
// products Product
await db.execute('''
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
product_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
unit_price REAL DEFAULT 0.0,
quantity INTEGER DEFAULT 0,
stock INTEGER DEFAULT 0,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// customers
await db.execute('''
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_code TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
address TEXT,
phone TEXT,
email TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// sales
await db.execute('''
CREATE TABLE sales (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER,
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'completed',
payment_status TEXT DEFAULT 'paid',
invoice_number TEXT UNIQUE,
notes TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
// estimates Estimate
await db.execute('''
CREATE TABLE estimates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
quote_number TEXT UNIQUE,
customer_id INTEGER REFERENCES customers(id),
product_id INTEGER REFERENCES products(id),
quantity INTEGER NOT NULL,
unit_price REAL NOT NULL,
discount_percent REAL DEFAULT 0.0,
total_amount REAL NOT NULL,
tax_rate REAL DEFAULT 8.0,
tax_amount REAL,
grand_total REAL NOT NULL,
status TEXT DEFAULT 'pending',
payment_status TEXT DEFAULT 'unpaid',
expiry_date TEXT,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
)
''');
//
await db.execute('CREATE INDEX idx_products_code ON products(product_code)');
await db.execute('CREATE INDEX idx_customers_code ON customers(customer_code)');
//
final sampleProducts = <Map<String, dynamic>>[
{'product_code': 'TEST001', 'name': 'サンプル商品 A', 'unit_price': 1000.0, 'quantity': 50, 'stock': 50},
{'product_code': 'TEST002', 'name': 'サンプル商品 B', 'unit_price': 2500.0, 'quantity': 30, 'stock': 30},
{'product_code': 'TEST003', 'name': 'サンプル商品 C', 'unit_price': 5000.0, 'quantity': 20, 'stock': 20},
];
for (final data in sampleProducts) {
await db.insert('products', data);
}
// Customer API
Future<int> insertCustomer(Customer customer) async {
final db = await database;
return await db.insert('customers', customer.toMap());
print('[DatabaseHelper] Sample products inserted');
}
Future<Customer?> getCustomer(int id) async {
final db = await database;
final results = await db.query('customers', where: 'id = ?', whereArgs: [id]);
if (results.isEmpty) return null;
return Customer.fromMap(results.first);
///
static Database get instance => _database!;
///
static Future<List<Product>> getProducts() async {
final result = await instance.query('products', orderBy: 'id DESC');
// DateTime Product
return List.generate(result.length, (index) {
final item = Map<String, dynamic>.from(result[index]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
Future<List<Customer>> getCustomers() async {
final db = await database;
final results = await db.query('customers');
return results.map((e) => Customer.fromMap(e)).toList();
return Product.fromMap(item);
});
}
Future<int> updateCustomer(Customer customer) async {
final db = await database;
return await db.update('customers', customer.toMap(), where: 'id = ?', whereArgs: [customer.id]);
/// ID null
static Future<Product?> getProduct(int id) async {
final result = await instance.query(
'products',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
Future<int> deleteCustomer(int id) async {
final db = await database;
return await db.delete('customers', where: 'id = ?', whereArgs: [id]);
return Product.fromMap(item);
}
return null;
}
// Product API
Future<int> insertProduct(Product product) async {
final db = await database;
return await db.insert('products', product.toMap());
/// productCode null
static Future<Product?> getProductByCode(String code) async {
final result = await instance.query(
'products',
where: 'product_code = ?',
whereArgs: [code],
);
if (result.isNotEmpty) {
final item = Map<String, dynamic>.from(result[0]);
if (item['created_at'] is DateTime) {
item['created_at'] = (item['created_at'] as DateTime).toIso8601String();
}
if (item['updated_at'] is DateTime) {
item['updated_at'] = (item['updated_at'] as DateTime).toIso8601String();
}
Future<Product?> getProduct(int id) async {
final db = await database;
final results = await db.query('products', where: 'id = ?', whereArgs: [id]);
if (results.isEmpty) return null;
return Product.fromMap(results.first);
return Product.fromMap(item);
}
return null;
}
Future<List<Product>> getProducts() async {
final db = await database;
final results = await db.query('products');
return results.map((e) => Product.fromMap(e)).toList();
/// ID null
static Future<Customer?> getCustomerById(int id) async {
final result = await instance.query(
'customers',
where: 'id = ?',
whereArgs: [id],
);
if (result.isNotEmpty) {
return Customer(
id: result[0]['id'] as int?,
customerCode: result[0]['customer_code'] as String?,
name: result[0]['name'] as String?,
address: result[0]['address'] as String?,
phone: result[0]['phone'] as String?,
email: result[0]['email'] as String?,
isInactive: (result[0]['is_inactive'] as bool?) ?? false,
);
}
return null;
}
Future<int> updateProduct(Product product) async {
final db = await database;
return await db.update('products', product.toMap(), where: 'id = ?', whereArgs: [product.id]);
/// return
static Future<void> insertProduct(Product product) async {
await instance.insert('products', {
'product_code': product.productCode,
'name': product.name,
'unit_price': product.unitPrice,
'quantity': product.quantity,
'stock': product.stock,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
}
Future<int> deleteProduct(int id) async {
final db = await database;
return await db.delete('products', where: 'id = ?', whereArgs: [id]);
///
static Future<void> deleteProduct(int id) async {
await instance.delete('products', where: 'id = ?', whereArgs: [id]);
}
// Sales API
Future<int> insertSales(Map<String, dynamic> salesData) async {
final db = await database;
return await db.insert('sales', salesData);
/// return
static Future<void> insertCustomer(Customer customer) async {
await instance.insert('customers', {
'customer_code': customer.customerCode,
'name': customer.name,
'address': customer.address,
'phone': customer.phone,
'email': customer.email,
'created_at': DateTime.now().toIso8601String(),
'updated_at': DateTime.now().toIso8601String(),
});
}
Future<List<Map<String, dynamic>>> getSales() async {
final db = await database;
return await db.query('sales');
///
static Future<void> deleteCustomer(int id) async {
await instance.delete('customers', where: 'id = ?', whereArgs: [id]);
}
Future<int> updateSales(Map<String, dynamic> salesData) async {
final db = await database;
return await db.update('sales', salesData, where: 'id = ?', whereArgs: [salesData['id'] as int]);
/// DB
static Future<void> clearDatabase() async {
await instance.delete('products');
await instance.delete('customers');
await instance.delete('sales');
await instance.delete('estimates');
}
Future<int> deleteSales(int id) async {
final db = await database;
return await db.delete('sales', where: 'id = ?', whereArgs: [id]);
/// + +
static Future<void> recover() async {
try {
// DB
final dbPath = Directory.current.path + '/data/db/sales.db';
final file = File(dbPath);
if (await file.exists()) {
await file.delete();
print('[DatabaseHelper] recover: DB ファイルを削除');
} else {
print('[DatabaseHelper] recover: DB ファイルが見つからない');
}
// Estimate API
Future<int> insertEstimate(Map<String, dynamic> estimateData) async {
final db = await database;
return await db.insert('estimates', estimateData);
//
await init();
} catch (e) {
print('[DatabaseHelper] recover error: $e');
}
}
Future<List<Map<String, dynamic>>> getEstimates() async {
final db = await database;
return await db.query('estimates');
}
Future<int> updateEstimate(Map<String, dynamic> estimateData) async {
final db = await database;
return await db.update('estimates', estimateData, where: 'id = ?', whereArgs: [estimateData['id'] as int]);
}
Future<int> deleteEstimate(int id) async {
final db = await database;
return await db.delete('estimates', where: 'id = ?', whereArgs: [id]);
}
// Invoice API
Future<int> insertInvoice(Map<String, dynamic> invoiceData) async {
final db = await database;
return await db.insert('invoices', invoiceData);
}
Future<List<Map<String, dynamic>>> getInvoices() async {
final db = await database;
return await db.query('invoices');
}
Future<int> updateInvoice(Map<String, dynamic> invoiceData) async {
final db = await database;
return await db.update('invoices', invoiceData, where: 'id = ?', whereArgs: [invoiceData['id'] as int]);
}
Future<int> deleteInvoice(int id) async {
final db = await database;
return await db.delete('invoices', where: 'id = ?', whereArgs: [id]);
}
// Inventory API
Future<int> insertInventory(Map<String, dynamic> inventoryData) async {
final db = await database;
return await db.insert('inventory', inventoryData);
}
Future<List<Map<String, dynamic>>> getInventory() async {
final db = await database;
return await db.query('inventory');
}
Future<int> updateInventory(Map<String, dynamic> inventoryData) async {
final db = await database;
return await db.update('inventory', inventoryData, where: 'id = ?', whereArgs: [inventoryData['id'] as int]);
}
Future<int> deleteInventory(int id) async {
final db = await database;
return await db.delete('inventory', where: 'id = ?', whereArgs: [id]);
}
// Employee API
Future<int> insertEmployee(Map<String, dynamic> employeeData) async {
final db = await database;
return await db.insert('employees', employeeData);
}
Future<List<Map<String, dynamic>>> getEmployees() async {
final db = await database;
return await db.query('employees');
}
Future<int> updateEmployee(Map<String, dynamic> employeeData) async {
final db = await database;
return await db.update('employees', employeeData, where: 'id = ?', whereArgs: [employeeData['id'] as int]);
}
Future<int> deleteEmployee(int id) async {
final db = await database;
return await db.delete('employees', where: 'id = ?', whereArgs: [id]);
}
// Warehouse API
Future<int> insertWarehouse(Map<String, dynamic> warehouseData) async {
final db = await database;
return await db.insert('warehouses', warehouseData);
}
Future<List<Map<String, dynamic>>> getWarehouses() async {
final db = await database;
return await db.query('warehouses');
}
Future<int> updateWarehouse(Map<String, dynamic> warehouseData) async {
final db = await database;
return await db.update('warehouses', warehouseData, where: 'id = ?', whereArgs: [warehouseData['id'] as int]);
}
Future<int> deleteWarehouse(int id) async {
final db = await database;
return await db.delete('warehouses', where: 'id = ?', whereArgs: [id]);
}
// Supplier API
Future<int> insertSupplier(Map<String, dynamic> supplierData) async {
final db = await database;
return await db.insert('suppliers', supplierData);
}
Future<List<Map<String, dynamic>>> getSuppliers() async {
final db = await database;
return await db.query('suppliers');
}
Future<int> updateSupplier(Map<String, dynamic> supplierData) async {
final db = await database;
return await db.update('suppliers', supplierData, where: 'id = ?', whereArgs: [supplierData['id'] as int]);
}
Future<int> deleteSupplier(int id) async {
final db = await database;
return await db.delete('suppliers', where: 'id = ?', whereArgs: [id]);
}
Future<void> close() async {
final db = await database;
db.close();
/// DB
static Future<String> getDbPath() async {
return Directory.current.path + '/data/db/sales.db';
}
}

View file

@ -0,0 +1,104 @@
// Version: 3.0 - 使
import 'package:flutter/material.dart';
import '../models/product.dart';
import '../services/database_helper.dart';
///
class MasterEditDialog<T extends Product> extends StatefulWidget {
final String title;
final T? initialData;
final bool showStatusFields;
final Function(T)? onSave;
const MasterEditDialog({
super.key,
required this.title,
this.initialData,
this.showStatusFields = false,
this.onSave,
});
@override
State<MasterEditDialog> createState() => _MasterEditDialogState();
}
class _MasterEditDialogState extends State<MasterEditDialog> {
late TextEditingController codeController;
late TextEditingController nameController;
@override
void initState() {
super.initState();
final data = widget.initialData;
codeController = TextEditingController(text: data?.productCode ?? '');
nameController = TextEditingController(text: data?.name ?? '');
}
bool showStatusField() => widget.showStatusFields;
Widget _buildEditField(String label, TextEditingController controller) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextField(controller: controller, decoration: InputDecoration(hintText: '入力をここに', border: OutlineInputBorder())),
],),
);
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(widget.title),
content: SingleChildScrollView(
child: Column(crossAxisAlignment: CrossAxisAlignment.stretch, children: [
ElevatedButton(
onPressed: () => Navigator.pop(context),
style: ElevatedButton.styleFrom(padding: const EdgeInsets.only(top: 8)),
child: const Text('キャンセル'),
),
_buildEditField('製品コード *', codeController),
_buildEditField('会社名 *', nameController),
if (widget.showStatusFields) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(border: Border.all(color: Colors.grey.shade300)),
child: const Text('ステータス管理(簡素版)」', textAlign: TextAlign.center),
),
],
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => Navigator.pop(context, widget.onSave?.call(widget.initialData! as T)),
child: const Text('保存'),
),
],),
),
);
}
}
///
class SingleChoiceDialog extends StatelessWidget {
final List<Product> items;
final Function() onCancel;
final Function(Product) onSelected;
const SingleChoiceDialog({super.key, required this.items, required this.onCancel, required this.onSelected});
@override
Widget build(BuildContext context) {
if (items.isEmpty) return const Text('検索結果がありません');
return ListView.builder(
shrinkWrap: true,
itemCount: items.length,
itemBuilder: (ctx, index) => ListTile(title: Text(items[index].name ?? '未入力'), onTap: () => onSelected(items[index])),
);
}
}

View file

@ -1,125 +1,146 @@
// Version: 1.0 - Flutter
// Version: 2.1 - 使
//
import 'package:flutter/material.dart';
/// TextField
///
class MasterTextField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hint;
final TextInputType keyboardType;
final bool obscureText;
final int maxLines;
final TextInputAction textInputAction;
final FormFieldValidator<String>? validator;
final void Function(String)? onChanged;
final String? hintText;
const MasterTextField({
super.key,
required this.label,
required this.controller,
this.hint,
this.keyboardType = TextInputType.text,
this.obscureText = false,
this.maxLines = 1,
this.textInputAction = TextInputAction.next,
this.validator,
this.onChanged,
this.hintText,
});
@override
Widget build(BuildContext context) {
return TextFormField(
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()),
),
keyboardType: keyboardType,
obscureText: obscureText,
maxLines: maxLines,
textInputAction: textInputAction,
validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation',
onChanged: onChanged,
],),
);
}
}
/// TextField
///
class MasterTextArea extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hintText;
final int maxLines;
const MasterTextArea({
super.key,
required this.label,
required this.controller,
this.hintText,
this.maxLines = 2,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextField(
controller: controller,
maxLines: maxLines,
decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()),
),
],),
);
}
}
///
class MasterNumberField extends StatelessWidget {
final String label;
final TextEditingController controller;
final String? hint;
final FormFieldValidator<String>? validator;
final void Function(String)? onChanged;
final String? hintText;
final bool readOnly;
const MasterNumberField({
super.key,
required this.label,
required this.controller,
this.hint,
this.validator,
this.hintText,
this.readOnly = false,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontWeight: FontWeight.bold)),
TextField(
controller: controller,
keyboardType: TextInputType.number,
readOnly: readOnly,
decoration: InputDecoration(hintText: hintText, border: OutlineInputBorder()),
),
],),
);
}
}
///
class MasterStatusField extends StatelessWidget {
final String label;
final String? status;
const MasterStatusField({super.key, required this.label, this.status});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
Expanded(child: Text(label, style: const TextStyle(fontWeight: FontWeight.bold))),
const SizedBox(width: 8),
if (status != null) ...[
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(borderRadius: BorderRadius.circular(4)),
child: Text(status!, style: const TextStyle(fontWeight: FontWeight.bold)),
),
],
],),
);
}
}
///
class MasterCheckboxField extends StatelessWidget {
final String label;
final bool? checked;
final Function(bool)? onChanged;
const MasterCheckboxField({
super.key,
required this.label,
this.checked,
this.onChanged,
});
@override
Widget build(BuildContext context) {
return TextFormField(
controller: controller,
decoration: InputDecoration(
labelText: label,
hintText: hint,
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
keyboardType: TextInputType.number,
validator: (value) => onChanged == null ? validator?.call(value) : 'Custom validation',
onChanged: onChanged,
);
}
}
/// Checkbox
class MasterCheckboxField extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool?>? onChangedCallback;
const MasterCheckboxField({
super.key,
required this.label,
required this.value,
this.onChangedCallback,
});
@override
Widget build(BuildContext context) {
return Checkbox(
value: value,
onChanged: onChangedCallback,
);
}
}
/// Switch
class MasterSwitchField extends StatelessWidget {
final String label;
final bool value;
final ValueChanged<bool>? onChangedCallback;
const MasterSwitchField({
super.key,
required this.label,
required this.value,
this.onChangedCallback,
});
@override
Widget build(BuildContext context) {
return Switch(
value: value,
onChanged: onChangedCallback,
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text(label),
Checkbox(value: checked ?? false, onChanged: onChanged),
],),
);
}
}

View file

@ -23,8 +23,9 @@ dependencies:
share_plus: ^10.1.2
google_sign_in: ^7.2.0
# フォームビルダ - マスタ編集の汎用モジュールで使用
flutter_form_builder: ^9.1.1
# リッチマスター編集用機能(簡易実装)
image_picker: ^1.0.7
qr_flutter: ^4.1.0
dev_dependencies:
flutter_test: