h-1.flutter.0/lib/screens/customer_picker_modal.dart
2026-02-26 17:29:22 +09:00

353 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

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';
import '../widgets/keyboard_inset_wrapper.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: KeyboardInsetWrapper(
basePadding: const EdgeInsets.fromLTRB(0, 0, 0, 24),
extraBottom: 24,
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(height: 1),
Expanded(
child: _isLoading
? const Center(child: CircularProgressIndicator())
: _filteredCustomers.isEmpty
? const Center(child: Text("該当する顧客がいません"))
: ListView.builder(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.only(bottom: 80),
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]),
),
),
),
],
),
);
}
}