hanbai1/lib/screens/customer_picker_modal.dart
2026-02-09 09:06:36 +09:00

427 lines
14 KiB
Dart

// lib/screens/customer_picker_modal.dart
// Version: 1.5.0 (Strict Type Handling: DB vs App Model)
import 'package:flutter/material.dart';
import 'package:flutter_contacts/flutter_contacts.dart';
import 'package:geolocator/geolocator.dart';
import 'package:uuid/uuid.dart';
import 'package:drift/drift.dart' as drift;
import '../models/app_model.dart' as app_model; // 名前を付けて区別
import '../data/database.dart' as db;
import '../main.dart';
class CustomerPickerModal extends StatefulWidget {
final Function(app_model.Customer) onCustomerSelected;
const CustomerPickerModal({Key? key, required this.onCustomerSelected})
: super(key: key);
@override
State<CustomerPickerModal> createState() => _CustomerPickerModalState();
}
class _CustomerPickerModalState extends State<CustomerPickerModal> {
String _searchQuery = "";
List<db.Customers> _dbCustomers = [];
List<app_model.Customer> _filteredCustomers = [];
bool _isLoading = true;
bool _isImportingFromContacts = false;
Position? _currentPosition;
@override
void initState() {
super.initState();
_refreshData();
}
Future<void> _refreshData() async {
if (!mounted) return;
setState(() => _isLoading = true);
try {
_currentPosition = await _determinePosition();
_dbCustomers = await database.select(database.customers).get();
_applyFilterAndSort();
} catch (e) {
debugPrint("Data Fetch Error: $e");
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _applyFilterAndSort() {
final query = _searchQuery.toLowerCase();
// DBの型をアプリ用の型に変換しながらフィルタリング
List<app_model.Customer> list = _dbCustomers
.where((c) {
return c.formalName.toLowerCase().contains(query) ||
c.displayName.toLowerCase().contains(query);
})
.map(
(c) => app_model.Customer(
id: c.id,
displayName: c.displayName,
formalName: c.formalName,
address: c.address ?? "",
department: c.department ?? "",
),
)
.toList();
// GPS距離でソート
if (_currentPosition != null) {
list.sort((a, b) {
final dbA = _dbCustomers.firstWhere((e) => e.id == a.id);
final dbB = _dbCustomers.firstWhere((e) => e.id == b.id);
if (dbA.latitude == null) return 1;
if (dbB.latitude == null) return -1;
double distA = Geolocator.distanceBetween(
_currentPosition!.latitude,
_currentPosition!.longitude,
dbA.latitude!,
dbA.longitude!,
);
double distB = Geolocator.distanceBetween(
_currentPosition!.latitude,
_currentPosition!.longitude,
dbB.latitude!,
dbB.longitude!,
);
return distA.compareTo(distB);
});
}
setState(() {
_filteredCustomers = list;
});
}
Future<Position?> _determinePosition() async {
try {
LocationPermission permission = await Geolocator.checkPermission();
if (permission == LocationPermission.denied) {
permission = await Geolocator.requestPermission();
}
if (permission == LocationPermission.always ||
permission == LocationPermission.whileInUse) {
return await Geolocator.getCurrentPosition(
desiredAccuracy: LocationAccuracy.low,
).timeout(const Duration(seconds: 3));
}
} catch (_) {}
return null;
}
void _onSearchChanged(String query) {
_searchQuery = query;
_applyFilterAndSort();
}
Future<void> _importFromPhoneContacts() async {
setState(() => _isImportingFromContacts = true);
try {
if (await FlutterContacts.requestPermission(readonly: true)) {
final contacts = await FlutterContacts.getContacts();
if (!mounted) return;
setState(() => _isImportingFromContacts = false);
final Contact? selectedContact = await showModalBottomSheet<Contact>(
context: context,
isScrollControlled: true,
builder: (context) => _PhoneContactListSelector(contacts: contacts),
);
if (selectedContact != null) {
_showCustomerEditDialog(
displayName: selectedContact.displayName,
initialFormalName: selectedContact.displayName,
);
}
}
} catch (e) {
setState(() => _isImportingFromContacts = false);
}
}
void _showCustomerEditDialog({
required String displayName,
required String initialFormalName,
app_model.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: "請求書用 正式名称",
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 newId = existingCustomer?.id ?? const Uuid().v4();
await database
.into(database.customers)
.insertOnConflictUpdate(
db.CustomersCompanion.insert(
id: newId,
displayName: displayName,
formalName: formalNameController.text.trim(),
department: drift.Value(departmentController.text.trim()),
address: drift.Value(addressController.text.trim()),
latitude: drift.Value(_currentPosition?.latitude),
longitude: drift.Value(_currentPosition?.longitude),
lastUpdatedAt: drift.Value(DateTime.now()),
),
);
if (mounted) {
Navigator.pop(context);
_refreshData();
}
},
child: const Text("保存"),
),
],
),
);
}
Future<void> _confirmDelete(app_model.Customer customer) async {
final confirm = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text("顧客の削除"),
content: Text("${customer.formalName}」を削除しますか?"),
actions: [
TextButton(
onPressed: () => Navigator.pop(context, false),
child: const Text("キャンセル"),
),
TextButton(
onPressed: () => Navigator.pop(context, true),
child: const Text("削除する", style: TextStyle(color: Colors.red)),
),
],
),
);
if (confirm == true) {
await (database.delete(
database.customers,
)..where((t) => t.id.equals(customer.id))).go();
_refreshData();
}
}
@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: _onSearchChanged,
),
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(),
if (_isLoading) const LinearProgressIndicator(),
Expanded(
child: _filteredCustomers.isEmpty && !_isLoading
? 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.address.isEmpty ? "住所未設定" : customer.address,
),
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 _controller = TextEditingController();
@override
void initState() {
super.initState();
_filtered = widget.contacts;
}
@override
Widget build(BuildContext context) {
return FractionallySizedBox(
heightFactor: 0.8,
child: Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: TextField(
controller: _controller,
decoration: const InputDecoration(
hintText: "電話帳検索...",
prefixIcon: Icon(Icons.search),
),
onChanged: (q) => setState(
() => _filtered = widget.contacts
.where(
(c) =>
c.displayName.toLowerCase().contains(q.toLowerCase()),
)
.toList(),
),
),
),
Expanded(
child: ListView.builder(
itemCount: _filtered.length,
itemBuilder: (context, index) => ListTile(
title: Text(_filtered[index].displayName),
onTap: () => Navigator.pop(context, _filtered[index]),
),
),
),
],
),
);
}
}