// 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 createState() => _CustomerPickerModalState(); } class _CustomerPickerModalState extends State { String _searchQuery = ""; List _dbCustomers = []; List _filteredCustomers = []; bool _isLoading = true; bool _isImportingFromContacts = false; Position? _currentPosition; @override void initState() { super.initState(); _refreshData(); } Future _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 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 _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 _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( 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 _confirmDelete(app_model.Customer customer) async { final confirm = await showDialog( 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 contacts; const _PhoneContactListSelector({required this.contacts}); @override State<_PhoneContactListSelector> createState() => _PhoneContactListSelectorState(); } class _PhoneContactListSelectorState extends State<_PhoneContactListSelector> { List _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]), ), ), ), ], ), ); } }