Files
superport/lib/screens/common/widgets/autocomplete_dropdown_field.dart

256 lines
8.3 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart'; // kDebugMode 사용
/// 드롭다운 + 자동완성 + 텍스트 입력을 모두 지원하는 공통 위젯
///
/// - 텍스트 입력 시 자동완성 추천 리스트 노출
/// - 드롭다운 버튼 클릭 시 전체 리스트 노출
/// - 직접 입력, 선택 모두 가능
/// - 재사용성 및 SRP 준수
class AutocompleteDropdownField extends StatefulWidget {
final String label;
final String value;
final List<String> items;
final bool isRequired;
final String hintText;
final void Function(String) onChanged;
final void Function(String) onSelected;
final bool enabled;
const AutocompleteDropdownField({
super.key,
required this.label,
required this.value,
required this.items,
required this.onChanged,
required this.onSelected,
this.isRequired = false,
this.hintText = '',
this.enabled = true,
});
@override
State<AutocompleteDropdownField> createState() =>
_AutocompleteDropdownFieldState();
}
class _AutocompleteDropdownFieldState extends State<AutocompleteDropdownField> {
late TextEditingController _controller;
late final FocusNode _focusNode;
late List<String> _filteredItems;
bool _showDropdown = false;
// 위젯 고유 키 추가 (동적 값 기반 키 대신 고정된 ValueKey 사용)
final GlobalKey _fieldKey = GlobalKey();
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.value);
if (kDebugMode) {
print(
'[AutocompleteDropdownField:initState] label=${widget.label}, value=${widget.value}',
);
}
_focusNode = FocusNode();
_filteredItems = List.from(widget.items);
_controller.addListener(_onTextChanged);
_focusNode.addListener(_handleFocusChange);
}
@override
void didUpdateWidget(covariant AutocompleteDropdownField oldWidget) {
super.didUpdateWidget(oldWidget);
// 항상 부모의 value와 내부 컨트롤러를 동기화 (동기화 누락 방지)
_controller.text = widget.value;
if (kDebugMode) {
print(
'[AutocompleteDropdownField:didUpdateWidget] label=${widget.label}, value 동기화: widget.value=${widget.value}, controller.text=${_controller.text}',
);
}
if (widget.items != oldWidget.items) {
_filteredItems = List.from(widget.items);
}
}
@override
void dispose() {
_focusNode.dispose();
_controller.dispose();
super.dispose();
}
void _handleFocusChange() {
setState(() {
// 포커스가 있고 필터링된 아이템이 있을 때만 드롭다운 표시
_showDropdown = _focusNode.hasFocus && _filteredItems.isNotEmpty;
// 포커스가 없으면 드롭다운 닫기
if (!_focusNode.hasFocus) {
_showDropdown = false;
}
});
if (kDebugMode) {
print(
'[AutocompleteDropdownField:_handleFocusChange] label=${widget.label}, hasFocus=${_focusNode.hasFocus}, showDropdown=$_showDropdown',
);
}
}
void _onTextChanged() {
final text = _controller.text;
if (kDebugMode) {
print(
'[AutocompleteDropdownField:_onTextChanged] label=${widget.label}, text=$text',
);
}
setState(() {
if (text.isEmpty) {
_filteredItems = List.from(widget.items);
} else {
_filteredItems =
widget.items
.where(
(item) => item.toLowerCase().contains(text.toLowerCase()),
)
.toList();
// 일치하는 아이템 정렬 (시작 부분 일치 항목 우선)
_filteredItems.sort((a, b) {
bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase());
bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase());
if (aStartsWith && !bStartsWith) return -1;
if (!aStartsWith && bStartsWith) return 1;
return a.compareTo(b);
});
}
// 포커스가 있고 필터링된 아이템이 있을 때만 드롭다운 표시
_showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus;
widget.onChanged(text);
});
}
void _handleSelect(String value) {
if (kDebugMode) {
print(
'[AutocompleteDropdownField:_handleSelect] 선택값=$value, 이전 값=${_controller.text}',
);
}
// 1. 값 전달 (부모 콜백)
widget.onChanged(value); // 입력값 변경 콜백
widget.onSelected(value); // 선택 콜백
// 2. 부모 setState 이후, 프레임이 끝난 뒤 드롭다운 닫기 (즉각 반영 보장)
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
_controller.text = value;
_controller.selection = TextSelection.collapsed(offset: value.length);
_showDropdown = false;
});
_focusNode.unfocus();
});
if (kDebugMode) {
print(
'[AutocompleteDropdownField:_handleSelect] 업데이트 완료: controller.text=${_controller.text}',
);
}
}
void _toggleDropdown() {
setState(() {
_showDropdown = !_showDropdown && _filteredItems.isNotEmpty;
if (_showDropdown) {
_focusNode.requestFocus();
}
});
}
@override
Widget build(BuildContext context) {
return Stack(
key: _fieldKey, // 고정된 키 사용
children: [
TextFormField(
controller: _controller,
focusNode: _focusNode,
enabled: widget.enabled,
decoration: InputDecoration(
labelText: widget.label,
hintText: widget.hintText,
suffixIcon: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (_controller.text.isNotEmpty)
IconButton(
icon: const Icon(Icons.clear),
onPressed:
widget.enabled
? () {
setState(() {
_controller.clear();
_filteredItems = List.from(widget.items);
_showDropdown =
_focusNode.hasFocus &&
_filteredItems.isNotEmpty;
widget.onSelected('');
});
}
: null,
),
IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: widget.enabled ? _toggleDropdown : null,
),
],
),
),
validator: (value) {
if (widget.isRequired && (value == null || value.isEmpty)) {
return '${widget.label}을(를) 입력해주세요';
}
return null;
},
onSaved: (value) {
widget.onSelected(value ?? '');
},
),
if (_showDropdown)
Positioned(
left: 0,
right: 0,
top: 56, // TextFormField 높이만큼 아래로
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: ListView.builder(
shrinkWrap: true,
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
if (kDebugMode) {
print(
'[AutocompleteDropdownField:GestureDetector:onTap] label=${widget.label}, 선택값=${_filteredItems[index]}',
);
}
_handleSelect(_filteredItems[index]);
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
child: Text(_filteredItems[index]),
),
);
},
),
),
),
),
],
);
}
}