427 lines
14 KiB
Dart
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]),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|