346 lines
13 KiB
Dart
346 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_contacts/flutter_contacts.dart';
|
||
import 'package:uuid/uuid.dart';
|
||
import '../models/customer_model.dart';
|
||
import '../services/customer_repository.dart';
|
||
|
||
/// 顧客マスターからの選択、登録、編集、削除を行うモーダル
|
||
class CustomerPickerModal extends StatefulWidget {
|
||
final Function(Customer) onCustomerSelected;
|
||
|
||
const CustomerPickerModal({
|
||
Key? key,
|
||
required this.onCustomerSelected,
|
||
}) : super(key: key);
|
||
|
||
@override
|
||
State<CustomerPickerModal> createState() => _CustomerPickerModalState();
|
||
}
|
||
|
||
class _CustomerPickerModalState extends State<CustomerPickerModal> {
|
||
final CustomerRepository _repository = CustomerRepository();
|
||
String _searchQuery = "";
|
||
List<Customer> _allCustomers = [];
|
||
List<Customer> _filteredCustomers = [];
|
||
bool _isImportingFromContacts = false;
|
||
bool _isLoading = true;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_onSearch(""); // 初期表示
|
||
}
|
||
|
||
Future<void> _onSearch(String query) async {
|
||
setState(() => _isLoading = true);
|
||
final customers = await _repository.searchCustomers(query);
|
||
setState(() {
|
||
_filteredCustomers = customers;
|
||
_isLoading = false;
|
||
});
|
||
}
|
||
|
||
/// 電話帳から取り込んで新規顧客として登録・編集するダイアログ
|
||
Future<void> _importFromPhoneContacts() async {
|
||
setState(() => _isImportingFromContacts = true);
|
||
try {
|
||
if (await FlutterContacts.requestPermission(readonly: true)) {
|
||
final contacts = await FlutterContacts.getContacts(withProperties: true, withAccounts: true, withPhoto: false);
|
||
if (!mounted) return;
|
||
setState(() => _isImportingFromContacts = false);
|
||
|
||
final Contact? selectedContact = await showModalBottomSheet<Contact?>(
|
||
context: context,
|
||
isScrollControlled: true,
|
||
builder: (context) => _PhoneContactListSelector(contacts: contacts),
|
||
);
|
||
|
||
if (selectedContact != null) {
|
||
final orgCompany = (selectedContact.organizations.isNotEmpty ? selectedContact.organizations.first.company : '') ?? '';
|
||
final personName = selectedContact.displayName;
|
||
final display = orgCompany.isNotEmpty ? orgCompany : personName;
|
||
final formal = orgCompany.isNotEmpty ? orgCompany : personName;
|
||
_showCustomerEditDialog(
|
||
displayName: display,
|
||
initialFormalName: formal,
|
||
);
|
||
}
|
||
}
|
||
} catch (e) {
|
||
setState(() => _isImportingFromContacts = false);
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text("電話帳の取得に失敗しました: $e")),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 顧客情報の編集・登録ダイアログ
|
||
void _showCustomerEditDialog({
|
||
required String displayName,
|
||
required String initialFormalName,
|
||
Customer? existingCustomer,
|
||
}) {
|
||
final formalNameController = TextEditingController(text: initialFormalName);
|
||
final departmentController = TextEditingController(text: existingCustomer?.department ?? "");
|
||
final addressController = TextEditingController(text: existingCustomer?.address ?? "");
|
||
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: Text(existingCustomer == null ? "顧客の新規登録" : "顧客情報の編集"),
|
||
content: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text("電話帳名: $displayName", style: const TextStyle(fontSize: 12, color: Colors.grey)),
|
||
const SizedBox(height: 16),
|
||
TextField(
|
||
controller: formalNameController,
|
||
decoration: const InputDecoration(
|
||
labelText: "請求書用 正式名称",
|
||
hintText: "株式会社 〇〇 など",
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: departmentController,
|
||
decoration: const InputDecoration(
|
||
labelText: "部署名",
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
controller: addressController,
|
||
decoration: const InputDecoration(
|
||
labelText: "住所",
|
||
border: OutlineInputBorder(),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||
ElevatedButton(
|
||
onPressed: () async {
|
||
final formal = formalNameController.text.trim();
|
||
if (formal.isEmpty) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('正式名称を入力してください')));
|
||
return;
|
||
}
|
||
String normalize(String s) {
|
||
var n = s.replaceAll(RegExp(r"\s+|\u3000"), "");
|
||
for (final token in ["株式会社", "(株)", "(株)", "有限会社", "(有)", "(有)", "合同会社", "(同)", "(同)"]) {
|
||
n = n.replaceAll(token, "");
|
||
}
|
||
return n.toLowerCase();
|
||
}
|
||
|
||
final normalizedFormal = normalize(formal);
|
||
final duplicates = await _repository.getAllCustomers();
|
||
final hasDuplicate = duplicates.any((c) {
|
||
final target = normalize(c.formalName);
|
||
return target == normalizedFormal && (existingCustomer == null || c.id != existingCustomer.id);
|
||
});
|
||
if (hasDuplicate) {
|
||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('同一顧客名が存在します')));
|
||
return;
|
||
}
|
||
|
||
final updatedCustomer = existingCustomer?.copyWith(
|
||
formalName: formal,
|
||
department: departmentController.text.trim(),
|
||
address: addressController.text.trim(),
|
||
updatedAt: DateTime.now(),
|
||
isSynced: false,
|
||
) ??
|
||
Customer(
|
||
id: const Uuid().v4(),
|
||
displayName: displayName,
|
||
formalName: formal,
|
||
department: departmentController.text.trim(),
|
||
address: addressController.text.trim(),
|
||
);
|
||
|
||
await _repository.saveCustomer(updatedCustomer);
|
||
Navigator.pop(context); // エディットダイアログを閉じる
|
||
_onSearch(_searchQuery); // リスト再読込
|
||
if (existingCustomer == null) {
|
||
widget.onCustomerSelected(updatedCustomer);
|
||
}
|
||
},
|
||
child: const Text("保存してマスターに登録"),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 削除確認ダイアログ
|
||
void _confirmDelete(Customer customer) {
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
title: const Text("顧客の削除"),
|
||
content: Text("「${customer.formalName}」をマスターから削除しますか?\n(過去の請求書ファイルは削除されません)"),
|
||
actions: [
|
||
TextButton(onPressed: () => Navigator.pop(context), child: const Text("キャンセル")),
|
||
TextButton(
|
||
onPressed: () async {
|
||
await _repository.deleteCustomer(customer.id);
|
||
Navigator.pop(context);
|
||
_onSearch("");
|
||
},
|
||
child: const Text("削除する", style: TextStyle(color: Colors.red)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Material(
|
||
child: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(16.0),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
const Text("顧客マスター管理", style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
|
||
IconButton(icon: const Icon(Icons.close), onPressed: () => Navigator.pop(context)),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
TextField(
|
||
decoration: InputDecoration(
|
||
hintText: "登録済み顧客を検索...",
|
||
prefixIcon: const Icon(Icons.search),
|
||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)),
|
||
),
|
||
onChanged: _onSearch,
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(
|
||
width: double.infinity,
|
||
child: ElevatedButton.icon(
|
||
onPressed: _isImportingFromContacts ? null : _importFromPhoneContacts,
|
||
icon: _isImportingFromContacts
|
||
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2))
|
||
: const Icon(Icons.contact_phone),
|
||
label: const Text("電話帳から新規取り込み"),
|
||
style: ElevatedButton.styleFrom(backgroundColor: Colors.blueGrey.shade700, foregroundColor: Colors.white),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const Divider(),
|
||
Expanded(
|
||
child: _isLoading
|
||
? const Center(child: CircularProgressIndicator())
|
||
: _filteredCustomers.isEmpty
|
||
? const Center(child: Text("該当する顧客がいません"))
|
||
: ListView.builder(
|
||
itemCount: _filteredCustomers.length,
|
||
itemBuilder: (context, index) {
|
||
final customer = _filteredCustomers[index];
|
||
return ListTile(
|
||
leading: const CircleAvatar(child: Icon(Icons.business)),
|
||
title: Text(customer.formalName),
|
||
subtitle: Text(customer.department?.isNotEmpty == true ? customer.department! : "部署未設定"),
|
||
onTap: () => widget.onCustomerSelected(customer),
|
||
trailing: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
IconButton(
|
||
icon: const Icon(Icons.edit, color: Colors.blueGrey, size: 20),
|
||
onPressed: () => _showCustomerEditDialog(
|
||
displayName: customer.displayName,
|
||
initialFormalName: customer.formalName,
|
||
existingCustomer: customer,
|
||
),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.delete_outline, color: Colors.redAccent, size: 20),
|
||
onPressed: () => _confirmDelete(customer),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 電話帳から一人選ぶための内部ウィジェット
|
||
class _PhoneContactListSelector extends StatefulWidget {
|
||
final List<Contact> contacts;
|
||
const _PhoneContactListSelector({required this.contacts});
|
||
|
||
@override
|
||
State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState();
|
||
}
|
||
|
||
class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> {
|
||
List<Contact> _filtered = [];
|
||
final _searchController = TextEditingController();
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_filtered = widget.contacts;
|
||
}
|
||
|
||
void _onSearch(String q) {
|
||
setState(() {
|
||
_filtered = widget.contacts
|
||
.where((c) {
|
||
final org = c.organizations.isNotEmpty ? c.organizations.first.company : '';
|
||
final label = org.isNotEmpty ? org : c.displayName;
|
||
return label.toLowerCase().contains(q.toLowerCase());
|
||
})
|
||
.toList();
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return FractionallySizedBox(
|
||
heightFactor: 0.8,
|
||
child: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.all(16.0),
|
||
child: TextField(
|
||
controller: _searchController,
|
||
decoration: const InputDecoration(hintText: "電話帳から検索...", prefixIcon: Icon(Icons.search)),
|
||
onChanged: _onSearch,
|
||
),
|
||
),
|
||
Expanded(
|
||
child: ListView.builder(
|
||
itemCount: _filtered.length,
|
||
itemBuilder: (context, index) => ListTile(
|
||
title: Text(((_filtered[index].organizations.isNotEmpty ? _filtered[index].organizations.first.company : '') ?? '').isNotEmpty
|
||
? _filtered[index].organizations.first.company
|
||
: _filtered[index].displayName),
|
||
onTap: () => Navigator.pop(context, _filtered[index]),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|