537 lines
No EOL
19 KiB
Dart
537 lines
No EOL
19 KiB
Dart
// マスター編集用汎用ウィジェット(簡易実装)+ 電話帳連携対応
|
||
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<MasterTextField> createState() => _MasterTextFieldState();
|
||
}
|
||
|
||
class _MasterTextFieldState extends State<MasterTextField> {
|
||
bool _isSearchingPhone = false;
|
||
String? _tempPhoneNumber;
|
||
|
||
Future<void> _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),
|
||
);
|
||
}
|
||
}
|
||
} |