// マスター編集用汎用ウィジェット(簡易実装)+ 電話帳連携対応 import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; import 'package:contacts_service/contacts_service.dart'; /// テキスト入力フィールド(マスター用)+ 電話帳取得ボタン付き class MasterTextField extends StatefulWidget { final String label; final TextEditingController controller; final TextInputType? keyboardType; final bool readOnly; final int? maxLines; final String? hintText; final String? initialValueText; final String? phoneField; // 電話番号フィールド(電話帳取得用) const MasterTextField({ super.key, required this.label, required this.controller, this.keyboardType, this.readOnly = false, this.maxLines, this.hintText, this.initialValueText, this.phoneField, }); @override State createState() => _MasterTextFieldState(); } class _MasterTextFieldState extends State { bool _isSearchingPhone = false; String? _tempPhoneNumber; Future _searchContactsForPhone() async { if (widget.readOnly) return; // UI に待機表示を表示 setState(() { _isSearchingPhone = true; }); try { final contacts = await ContactsService.getContacts(limit: 10); final selectedContact = contacts.firstWhere( (contact) => contact.phones.any((phone) => phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') == widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '')), orElse: () => null, ); if (selectedContact != null && selectedContact.phones.isNotEmpty) { // 番号を一致するものを選択 final matchingPhone = selectedContact.phones.firstWhere( (phone) => phone.number.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', '') == widget.controller.text.replaceAll(' ', '').replaceAll('-', '').replaceAll('/', ''), orElse: () => selectedContact.phones.first, ); setState(() { _tempPhoneNumber = matchingPhone.number; }); } } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('連絡先検索に失敗しました:$e'), backgroundColor: Colors.orange), ); } } finally { if (mounted) { setState(() { _isSearchingPhone = false; }); } } } @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded(child: Text(widget.label, style: const TextStyle(fontWeight: FontWeight.bold))), if (!widget.readOnly && widget.phoneField != null) _buildPhoneSearchButton(), ], ), SizedBox(height: 4), Container( constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策:最大高制限 child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ TextField( controller: widget.controller, decoration: InputDecoration( hintText: widget.hintText ?? (widget.initialValueText != null ? widget.initialValueText : null), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), ), keyboardType: widget.keyboardType, readOnly: widget.readOnly, maxLines: widget.maxLines ?? (widget.phoneField == null ? 1 : 2), // 電話番号時は最大 2 行 ), if (_isSearchingPhone && _tempPhoneNumber != null) Padding( padding: const EdgeInsets.only(top: 4.0), child: Row( children: [ Icon(Icons.hourglass_empty, size: 16, color: Colors.orange), SizedBox(width: 8), Text('電話帳から取得中...', style: TextStyle(color: Colors.orange[700])), ], ), ), if (_tempPhoneNumber != null) Padding( padding: const EdgeInsets.only(top: 4.0), child: Row( children: [ Icon(Icons.check_circle, size: 16, color: Colors.green), SizedBox(width: 8), Text('見つかりました:$_tempPhoneNumber', style: TextStyle(color: Colors.green[700])), IconButton( icon: const Icon(Icons.close, size: 16), onPressed: () { setState(() { _tempPhoneNumber = null; }); }, ), ], ), ), ], ), ), ], ), ); } Widget _buildPhoneSearchButton() { return IconButton( icon: const Icon(Icons.person_search), tooltip: '電話帳から取得', onPressed: _searchContactsForPhone, ); } } /// 数値入力フィールド(マスター用) class MasterNumberField extends StatelessWidget { final String label; final TextEditingController controller; final String? hintText; final bool readOnly; final String? initialValueText; const MasterNumberField({ super.key, required this.label, required this.controller, this.hintText, this.readOnly = false, this.initialValueText, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), SizedBox(height: 4), Container( constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策 child: TextField( controller: controller, decoration: InputDecoration( hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲(例:3)'), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), ), keyboardType: TextInputType.number, readOnly: readOnly, ), ), ], ), ); } } /// 日付入力フィールド(マスター用) class MasterDateField extends StatelessWidget { final String label; final TextEditingController controller; final DateTime? picked; const MasterDateField({ super.key, required this.label, required this.controller, this.picked, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Row( children: [ Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), SizedBox(height: 4), Container( constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策 child: TextField( controller: controller, decoration: InputDecoration( border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), ), readOnly: true, ), ), ], )), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.calendar_today), onPressed: () async { final picked = await showDatePicker( context: context, initialDate: DateTime.now(), firstDate: DateTime(2020), lastDate: DateTime(2030), ); if (picked != null) { controller.text = DateFormat('yyyy/MM/dd').format(picked); } }, ), ], ), ); } } /// テキストフィールド用カスタムフォーカスノード(QR コード生成対応) class MasterTextFieldNode extends FocusNode { final GlobalKey key; final String? value; final bool isEditable; MasterTextFieldNode({Key? key, this.value, this.isEditable = true}) : key = GlobalKey(); @override FocusableState get state => key.currentState as FocusableState; } /// テキストフィールド用カスタムフォーカスノード(QR コード生成対応) class MasterNumberFieldNode extends FocusNode { final GlobalKey key; final String? value; final bool isEditable; MasterNumberFieldNode({Key? key, this.value, this.isEditable = true}) : key = GlobalKey(); @override FocusableState get state => key.currentState as FocusableState; } /// リッチマスターテキストフィールド(汎用性高く、他のマスタ参照可) class RichMasterTextField extends StatelessWidget { final String label; final TextEditingController controller; final TextInputType? keyboardType; final bool readOnly; final int? maxLines; final String? hintText; final String? initialValueText; const RichMasterTextField({ super.key, required this.label, required this.controller, this.keyboardType, this.readOnly = false, this.maxLines, this.hintText, this.initialValueText, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), if (initialValueText != null) IconButton( icon: const Icon(Icons.copy), onPressed: () => _copyToClipboard(initialValueText!), ), ], ), SizedBox(height: 4), Container( constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策 child: TextField( controller: controller, decoration: InputDecoration( hintText: hintText ?? (initialValueText != null ? initialValueText : null), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), ), keyboardType: keyboardType, readOnly: readOnly, maxLines: maxLines ?? 1, ), ), ], ), ); } void _copyToClipboard(String text) async { try { await Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green), ); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red), ); } } } /// リッチマスター日付フィールド(汎用性高く、他のマスタ参照可) class RichMasterDateField extends StatelessWidget { final String label; final TextEditingController controller; final DateTime? picked; const RichMasterDateField({ super.key, required this.label, required this.controller, this.picked, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Row( children: [ Expanded(child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), if (picked != null) IconButton( icon: const Icon(Icons.copy), onPressed: () => _copyToDate(controller.text), ), ], ), SizedBox(height: 4), Container( constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策 child: TextField( controller: controller, decoration: InputDecoration( border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), ), readOnly: true, ), ), ], )), const SizedBox(width: 8), IconButton( icon: const Icon(Icons.calendar_today), onPressed: () async { final picked = await showDatePicker( context: context, initialDate: DateTime.now(), firstDate: DateTime(2020), lastDate: DateTime(2030), ); if (picked != null) { controller.text = DateFormat('yyyy/MM/dd').format(picked); } }, ), ], ), ); } void _copyToDate(String dateStr) async { try { await Clipboard.setData(ClipboardData(text: dateStr)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('日付をコピーしました'), backgroundColor: Colors.green), ); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red), ); } } } /// リッチマスター数値フィールド(汎用性高く、他のマスタ参照可) class RichMasterNumberField extends StatelessWidget { final String label; final TextEditingController controller; final String? hintText; final bool readOnly; final String? initialValueText; const RichMasterNumberField({ super.key, required this.label, required this.controller, this.hintText, this.readOnly = false, this.initialValueText, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text(label, style: const TextStyle(fontWeight: FontWeight.bold)), if (initialValueText != null) IconButton( icon: const Icon(Icons.copy), onPressed: () => _copyToClipboard(initialValueText!), ), ], ), SizedBox(height: 4), Container( constraints: BoxConstraints(maxHeight: 50), // セリ上がり病対策 child: TextField( controller: controller, decoration: InputDecoration( hintText: hintText ?? (initialValueText != null ? initialValueText : '1-5 の範囲(例:3)'), border: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(8)), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(8), borderSide: BorderSide(color: Theme.of(context).primaryColor, width: 2), ), contentPadding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 16.0), ), keyboardType: TextInputType.number, readOnly: readOnly, ), ), ], ), ); } void _copyToClipboard(String text) async { try { await Clipboard.setData(ClipboardData(text: text)); ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('コピーしました'), backgroundColor: Colors.green), ); } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('コピーに失敗:$e'), backgroundColor: Colors.red), ); } } }