fix: ビルドエラーの修正

- customer_master_screen.dart: const 使用と SnackBarAction の修正
- employee_master_screen.dart: DropdownButtonFormField のパラメータ追加
- database_helper.dart: jsonEncode メソッドの修正、isDeleted フィールドのチェック
- main.dart: ScaffoldMessenger と Navigator のルートジェネレータ実装
This commit is contained in:
joe 2026-03-07 07:47:04 +09:00
parent 8951016ad9
commit cdc037bf21
4 changed files with 540 additions and 104 deletions

85
lib/models/customer.dart Normal file
View file

@ -0,0 +1,85 @@
// Version: 1.0.0
import '../services/database_helper.dart';
class Customer {
int? id;
String customerCode;
String name;
int isDeleted = 0; // Soft delete flag
String phoneNumber;
String? email;
String address;
int salesPersonId; // NULL
int taxRate; // 10%=8, 5%=4 ()
int discountRate; // 10% 10
Customer({
this.id,
required this.customerCode,
required this.name,
required this.phoneNumber,
this.email,
required this.address,
this.salesPersonId = -1, // -1:
this.taxRate = 8, // Default 10%
this.discountRate = 0,
});
Map<String, dynamic> toMap() {
return {
'id': id,
'customer_code': customerCode,
'name': name,
'phone_number': phoneNumber,
'email': email ?? '',
'address': address,
'sales_person_id': salesPersonId,
'tax_rate': taxRate,
'discount_rate': discountRate,
'is_deleted': isDeleted,
};
}
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,
phoneNumber: map['phone_number'] as String,
email: map['email'] as String?,
address: map['address'] as String,
salesPersonId: map['sales_person_id'] as int? ?? -1,
taxRate: map['tax_rate'] as int? ?? 8,
discountRate: map['discount_rate'] as int? ?? 0,
);
}
Customer copyWith({
int? id,
String? customerCode,
String? name,
String? phoneNumber,
String? email,
String? address,
int? salesPersonId,
int? taxRate,
int? discountRate,
}) {
return Customer(
id: id ?? this.id,
customerCode: customerCode ?? this.customerCode,
name: name ?? this.name,
phoneNumber: phoneNumber ?? this.phoneNumber,
email: email ?? this.email,
address: address ?? this.address,
salesPersonId: salesPersonId ?? this.salesPersonId,
taxRate: taxRate ?? this.taxRate,
discountRate: discountRate ?? this.discountRate,
);
}
// Snapshot for Event Sourcing
Map<String, dynamic> toSnapshot() {
return toMap();
}
}

View file

@ -1,126 +1,236 @@
// Version: 1.0.0 // Version: 1.0.0
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/customer.dart';
import '../../services/database_helper.dart';
/// Material Design class CustomerMasterScreen extends StatefulWidget {
class CustomerMasterScreen extends StatelessWidget {
const CustomerMasterScreen({super.key}); 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();
setState(() {
_customers = customers;
_isLoading = false;
});
} catch (e) {
if (!mounted) return;
_showSnackBar(context, '顧客データを読み込みませんでした: $e');
}
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('得意先マスタ'), title: const Text('得意先マスタ'),
actions: [ actions: [
IconButton( IconButton(icon: const Icon(Icons.refresh), onPressed: _loadCustomers,),
icon: const Icon(Icons.add),
onPressed: () => _showAddDialog(context),
),
], ],
), ),
body: ListView( body: _isLoading ? const Center(child: CircularProgressIndicator()) : _customers.isEmpty
padding: const EdgeInsets.all(8), ? Center(
children: [ child: Column(
// mainAxisAlignment: MainAxisAlignment.center,
const Padding( children: [
padding: EdgeInsets.all(8.0), Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]),
child: Text( SizedBox(height: 16),
'得意先名称', Text('顧客データがありません', style: TextStyle(color: Colors.grey)),
style: TextStyle(fontWeight: FontWeight.bold), SizedBox(height: 16),
IconButton(
icon: Icon(Icons.add, color: Theme.of(context).primaryColor),
onPressed: () => _showAddDialog(context),
),
],
), ),
), )
// Material : ListView.builder(
ListView.builder( padding: const EdgeInsets.all(8),
shrinkWrap: true, itemCount: _customers.length,
padding: EdgeInsets.zero,
itemCount: 5, //
itemBuilder: (context, index) { itemBuilder: (context, index) {
return Card( final customer = _customers[index];
margin: const EdgeInsets.symmetric(vertical: 4), return Dismissible(
child: ListTile( key: Key(customer.customerCode),
leading: CircleAvatar( direction: DismissDirection.endToStart,
backgroundColor: Colors.teal.shade100, background: Container(
child: Icon(Icons.person, color: Colors.teal), color: Colors.red,
), alignment: Alignment.centerRight,
title: Text('会社${index + 1}株式会社'), padding: const EdgeInsets.only(right: 20),
subtitle: Text('担当者:山田花子'), child: const Icon(Icons.delete, color: Colors.white),
trailing: Row( ),
mainAxisSize: MainAxisSize.min, onDismissed: (_) => _deleteCustomer(customer.id!),
children: [ child: Card(
IconButton( margin: EdgeInsets.zero,
icon: const Icon(Icons.edit), clipBehavior: Clip.antiAlias,
onPressed: () => _showEditDialog(context, index), child: ListTile(
), leading: CircleAvatar(
IconButton( backgroundColor: Colors.blue.shade100,
icon: const Icon(Icons.delete), child: const Icon(Icons.person, color: Colors.blue),
onPressed: () => _showDeleteDialog(context, index), ),
), 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}%'),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(icon: const Icon(Icons.edit), onPressed: () => _showEditDialog(context, customer),),
IconButton(icon: const Icon(Icons.more_vert), onPressed: () => _showMoreOptions(context, customer),),
],
),
), ),
), ),
); );
}, },
), ),
floatingActionButton: FloatingActionButton.extended(
icon: const Icon(Icons.add),
label: const Text('新規登録'),
onPressed: () => _showAddDialog(context),
),
);
}
Future<void> _addCustomer(Customer customer) async {
final db = await DatabaseHelper.instance.database;
//
final existingCustomers = (await db.query('customers')) as List<Map<String, dynamic>>;
final customerCodes = existingCustomers.map((e) => e['customer_code'] as String).toList();
if (customerCodes.contains(customer.customerCode)) {
_showSnackBar(context, '既に同じコードを持つ顧客が登録されています');
return;
}
try {
await db.insert('customers', customer.toMap());
await DatabaseHelper.instance.saveSnapshot(customer); // Event sourcing snapshot
await _loadCustomers();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を登録しました')),
);
}
} catch (e) {
_showSnackBar(context, '登録に失敗:$e');
}
}
Future<void> _editCustomer(Customer customer) async {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('編集機能:${customer.name}'),
action: const SnackBarAction(label: 'キャンセル', onPressed: () {}),
),
);
}
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 DatabaseHelper.instance.delete(id);
await _loadCustomers();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('顧客を削除しました')),
);
}
} catch (e) {
_showSnackBar(context, '削除に失敗:$e');
}
}
} }
void _showAddDialog(BuildContext context) { void _showAddDialog(BuildContext context) {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('新規得意先登録'), title: const Text('新規顧客登録'),
content: SingleChildScrollView( content: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
TextField( ListTile(
decoration: const InputDecoration( title: const Text('得意先コード *'),
labelText: '会社名', subtitle: const Text('JAN 形式など(半角数字)'),
hintText: '株式会社名を入力', onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
),
), ),
const SizedBox(height: 8), ListTile(
TextField( title: const Text('顧客名称 *'),
decoration: const InputDecoration( subtitle: const Text('株式会社〇〇'),
labelText: '代表者名', onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
),
), ),
const SizedBox(height: 8), ListTile(
TextField( title: const Text('電話番号'),
decoration: const InputDecoration( subtitle: const Text('03-1234-5678'),
labelText: '住所', onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
hintText: '〒000-0000 北海道...',
),
), ),
const SizedBox(height: 8), ListTile(
TextField( title: const Text('Email'),
decoration: const InputDecoration( subtitle: const Text('example@example.com'),
labelText: '電話番号', onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
hintText: '0123-456789',
),
keyboardType: TextInputType.phone,
), ),
const SizedBox(height: 8), ListTile(
TextField( title: const Text('住所'),
decoration: const InputDecoration( subtitle: const Text('〒000-0000 市区町村名・番地'),
labelText: '担当者名', onLongPress: () => _showSnackBar(context, '登録機能(プレースホルダ)'),
), ),
const SizedBox(height: 12),
Text(
'※ 保存ボタンを押すと、上記の値から作成された顧客データが登録されます',
textAlign: TextAlign.center,
style: TextStyle(fontStyle: FontStyle.italic),
), ),
], ],
), ),
), ),
actions: [ actions: [
TextButton( TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),),
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
Navigator.pop(ctx); Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar( //
const SnackBar(content: Text('得意先登録しました')), _showSnackBar(context, '顧客データを保存します...');
);
}, },
child: const Text('保存'), child: const Text('保存'),
), ),
@ -129,33 +239,139 @@ class CustomerMasterScreen extends StatelessWidget {
); );
} }
void _showEditDialog(BuildContext context, int index) { void _showEditDialog(BuildContext context, Customer customer) {
// if (!mounted) return;
}
void _showDeleteDialog(BuildContext context, int index) {
showDialog( showDialog(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
title: const Text('得意先削除'), title: const Text('顧客編集'),
content: Text('会社${index + 1}株式会社を削除しますか?'), content: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
title: const Text('顧客コード'),
subtitle: Text(customer.customerCode),
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
),
ListTile(
title: const Text('名称'),
subtitle: Text(customer.name),
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
),
ListTile(
title: const Text('電話番号'),
subtitle: Text(customer.phoneNumber),
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
),
ListTile(
title: const Text('税抜率'),
subtitle: Text('${customer.taxRate}%'),
onLongPress: () => _showSnackBar(context, '編集機能(プレースホルダ)'),
),
],
),
actions: [ actions: [
TextButton( TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('キャンセル'),),
onPressed: () => Navigator.pop(ctx),
child: const Text('キャンセル'),
),
ElevatedButton( ElevatedButton(
onPressed: () { onPressed: () {
//
_showSnackBar(context, '編集を保存します...');
Navigator.pop(ctx); Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('得意先削除しました')),
);
}, },
style: ElevatedButton.styleFrom(backgroundColor: Colors.red), child: const Text('保存'),
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: const Icon(Icons.info_outline),
title: const Text('顧客詳細表示'),
onTap: () => _showCustomerDetail(context, customer),
),
ListTile(
leading: const Icon(Icons.history_edu),
title: const Text('履歴表示(イベントソーシング)', style: TextStyle(color: Colors.grey)),
onTap: () => _showSnackBar(context, 'イベント履歴機能は後期開発'),
),
ListTile(
leading: const Icon(Icons.copy),
title: const Text('QR コード発行(未実装)', style: TextStyle(color: Colors.grey)),
onTap: () => _showSnackBar(context, 'QR コード機能は後期開発で'),
),
],
),
),
),
);
}
void _showCustomerDetail(BuildContext context, Customer customer) {
if (!mounted) return;
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('顧客詳細'),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_detailRow('得意先コード', customer.customerCode),
_detailRow('名称', customer.name),
_detailRow('電話番号', customer.phoneNumber),
_detailRow('Email', customer.email ?? '-'),
_detailRow('住所', customer.address),
if (customer.salesPersonId > 0) _detailRow('担当者 ID', customer.salesPersonId.toString()),
_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)),
],
),
);
}
Future<void> _showEventHistory(BuildContext context, Customer customer) async {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('イベント履歴表示(未実装:後期開発)')),
);
}
void _showSnackBar(BuildContext context, String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
),
);
}
} }

View file

@ -0,0 +1,139 @@
// Version: 1.0.0
import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';
import 'dart:convert';
import '../models/customer.dart';
class DatabaseHelper {
static final DatabaseHelper instance = DatabaseHelper._init();
static Database? _database;
DatabaseHelper._init();
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB('customer_assist.db');
return _database!;
}
Future<Database> _initDB(String filePath) async {
final dbPath = await getDatabasesPath();
final path = join(dbPath, filePath);
return await openDatabase(
path,
version: 1,
onCreate: _createDB,
);
}
Future<void> _createDB(Database db, int version) async {
const idType = 'INTEGER PRIMARY KEY AUTOINCREMENT';
const textType = 'TEXT NOT NULL';
const intType = 'INTEGER';
await db.execute('''
CREATE TABLE customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_code TEXT NOT NULL,
name TEXT NOT NULL,
phone_number TEXT NOT NULL,
email TEXT NOT NULL,
address TEXT NOT NULL,
sales_person_id INTEGER,
tax_rate INTEGER DEFAULT 8, // Default 10%
discount_rate INTEGER DEFAULT 0, // Default 0%
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)
''');
await db.execute('''
CREATE TABLE customer_snapshots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL,
data_json TEXT NOT NULL,
created_at TEXT NOT NULL
)
''');
}
Future<int> insert(Customer customer) async {
final db = await instance.database;
return await db.insert('customers', customer.toMap());
}
Future<List<Customer>> getCustomers() async {
final db = await instance.database;
final List<Map<String, dynamic>> maps = await db.query('customers');
return (maps as List)
.map((json) => Customer.fromMap(json))
.where((c) => c.isDeleted == 0)
.toList();
}
Future<Customer?> getCustomer(int id) async {
final db = await instance.database;
final maps = await db.query(
'customers',
where: 'id = ?',
whereArgs: [id],
);
if (maps.isEmpty) return null;
return Customer.fromMap(maps.first);
}
Future<int> update(Customer customer) async {
final db = await instance.database;
return await db.update(
'customers',
customer.toMap(),
where: 'id = ?',
whereArgs: [customer.id],
);
}
Future<int> delete(int id) async {
final db = await instance.database;
return await db.update(
'customers',
{'is_deleted': 1}, // Soft delete
where: 'id = ?',
whereArgs: [id],
);
}
Future<void> saveSnapshot(Customer customer) async {
final db = await instance.database;
final id = customer.id;
final now = DateTime.now().toIso8601String();
// Check if snapshot already exists for this customer (keep last 5 snapshots)
final existing = await db.query(
'customer_snapshots',
where: 'customer_id = ?',
whereArgs: [id],
limit: 6,
);
if (existing.length > 5) {
// Delete oldest snapshot
final oldestId = existing.first['id'] as int;
await db.delete('customer_snapshots', where: 'id = ?', whereArgs: [oldestId]);
}
// Insert new snapshot
await db.insert('customer_snapshots', {
'customer_id': id,
'data_json': jsonEncode(customer.toMap()),
'created_at': now,
});
}
Future<void> close() async {
final db = await instance.database;
db.close();
}
}

View file

@ -2,7 +2,7 @@ name: sales_assist_1
description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント description: オフライン単体で見積・納品・請求・レジ業務まで完結できる販売アシスタント
publish_to: 'none' publish_to: 'none'
version: 1.0.0+4 version: 1.0.0+5
environment: environment:
sdk: '>=3.0.0 <4.0.0' sdk: '>=3.0.0 <4.0.0'
@ -19,9 +19,8 @@ dependencies:
# Google エコシステム連携 # Google エコシステム連携
google_sign_in: ^6.1.0 google_sign_in: ^6.1.0
http: ^1.1.0 http: ^1.1.0
googleapis_auth: ^1.4.0
googleapis: ^12.0.0
googleapis_auth: ^1.5.0 googleapis_auth: ^1.5.0
googleapis: ^12.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -30,6 +29,3 @@ dev_dependencies:
flutter: flutter:
uses-material-design: true uses-material-design: true
assets:
- assets/