From cefcfaac0d79adde5f672047a003e86b6cc92b3b Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Wed, 22 Oct 2025 14:31:45 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9E=90=EB=8F=99=EC=99=84=EC=84=B1=20?= =?UTF-8?q?=ED=8F=AC=EC=BB=A4=EC=8A=A4=20=EC=9C=A0=EC=A7=80=20=EB=B0=8F=20?= =?UTF-8?q?=EB=94=94=EB=B2=84=EA=B7=B8=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/approver_autocomplete_field.dart | 134 ++++++++++-------- .../widgets/employee_autocomplete_field.dart | 119 +++++++++------- .../widgets/product_autocomplete_field.dart | 128 ++++++++++------- .../widgets/warehouse_select_field.dart | 83 +++++++---- .../controllers/product_controller.dart | 8 ++ .../presentation/pages/product_page.dart | 14 +- .../widgets/uom_autocomplete_field.dart | 128 +++++++++++++---- .../widgets/vendor_autocomplete_field.dart | 128 +++++++++++++---- 8 files changed, 486 insertions(+), 256 deletions(-) diff --git a/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart b/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart index e330129..6e01b10 100644 --- a/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart +++ b/lib/features/approvals/shared/widgets/approver_autocomplete_field.dart @@ -155,16 +155,60 @@ class _ApprovalApproverAutocompleteFieldState _applyManualEntry(textController.text); onFieldSubmitted(); }, + onPressedOutside: (event) { + // 드롭다운에서 항목을 고르기 전에 포커스를 잃지 않도록 한다. + focusNode.requestFocus(); + }, ); }, optionsViewBuilder: (context, onSelected, options) { if (options.isEmpty) { - return Align( + return Listener( + onPointerDown: (_) { + if (!_focusNode.hasPrimaryFocus) { + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: AlignmentDirectional.topStart, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth, + maxHeight: 220, + ), + child: Material( + elevation: 6, + color: theme.colorScheme.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: theme.colorScheme.border), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + '일치하는 승인자를 찾지 못했습니다.', + style: theme.textTheme.muted, + ), + ), + ), + ), + ), + ), + ); + } + return Listener( + onPointerDown: (_) { + if (!_focusNode.hasPrimaryFocus) { + _focusNode.requestFocus(); + } + }, + child: Align( alignment: AlignmentDirectional.topStart, child: ConstrainedBox( constraints: BoxConstraints( maxWidth: constraints.maxWidth, - maxHeight: 220, + maxHeight: 260, ), child: Material( elevation: 6, @@ -173,64 +217,38 @@ class _ApprovalApproverAutocompleteFieldState borderRadius: BorderRadius.circular(12), side: BorderSide(color: theme.colorScheme.border), ), - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 24), - child: Text( - '일치하는 승인자를 찾지 못했습니다.', - style: theme.textTheme.muted, - ), - ), - ), - ), - ), - ); - } - return Align( - alignment: AlignmentDirectional.topStart, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth, - maxHeight: 260, - ), - child: Material( - elevation: 6, - color: theme.colorScheme.background, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.border), - ), - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 6), - itemCount: options.length, - itemBuilder: (context, index) { - final option = options.elementAt(index); - return InkWell( - onTap: () => onSelected(option), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${option.name} · ${option.team}', - style: theme.textTheme.p, - ), - const SizedBox(height: 4), - Text( - 'ID ${option.id} · ${option.employeeNo}', - style: theme.textTheme.muted.copyWith( - fontSize: 12, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + return InkWell( + onTap: () => onSelected(option), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${option.name} · ${option.team}', + style: theme.textTheme.p, ), - ), - ], + const SizedBox(height: 4), + Text( + 'ID ${option.id} · ${option.employeeNo}', + style: theme.textTheme.muted.copyWith( + fontSize: 12, + ), + ), + ], + ), ), - ), - ); - }, + ); + }, + ), ), ), ), diff --git a/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart b/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart index 0fba14c..d4f4f7d 100644 --- a/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart +++ b/lib/features/inventory/shared/widgets/employee_autocomplete_field.dart @@ -198,6 +198,10 @@ class _InventoryEmployeeAutocompleteFieldState placeholder: Text(widget.placeholder), onChanged: (_) => widget.onChanged?.call(), onSubmitted: (_) => onFieldSubmitted(), + onPressedOutside: (event) { + // 드롭다운 선택 중 포커스를 유지해 리스트가 즉시 닫히는 문제를 방지한다. + focusNode.requestFocus(); + }, ); if (!_isSearching) { return input; @@ -219,61 +223,78 @@ class _InventoryEmployeeAutocompleteFieldState }, optionsViewBuilder: (context, onSelected, options) { if (options.isEmpty) { - return Align( - alignment: AlignmentDirectional.topStart, - child: Material( - elevation: 6, - color: theme.colorScheme.background, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.border), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Center( - child: Text('일치하는 직원이 없습니다.', style: theme.textTheme.muted), + return Listener( + onPointerDown: (_) { + // 포커스가 사라지면 항목 선택 전에 닫히므로 즉시 복구한다. + if (!_focusNode.hasPrimaryFocus) { + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: AlignmentDirectional.topStart, + child: Material( + elevation: 6, + color: theme.colorScheme.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: theme.colorScheme.border), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Center( + child: Text('일치하는 직원이 없습니다.', style: theme.textTheme.muted), + ), ), ), ), ); } - return Align( - alignment: AlignmentDirectional.topStart, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 240, maxWidth: 360), - child: Material( - elevation: 6, - color: theme.colorScheme.background, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.border), - ), - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 6), - itemCount: options.length, - itemBuilder: (context, index) { - final suggestion = options.elementAt(index); - return InkWell( - onTap: () => onSelected(suggestion), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + return Listener( + onPointerDown: (_) { + if (!_focusNode.hasPrimaryFocus) { + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: AlignmentDirectional.topStart, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240, maxWidth: 360), + child: Material( + elevation: 6, + color: theme.colorScheme.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: theme.colorScheme.border), + ), + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: options.length, + itemBuilder: (context, index) { + final suggestion = options.elementAt(index); + return InkWell( + onTap: () => onSelected(suggestion), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(suggestion.name, style: theme.textTheme.p), + const SizedBox(height: 4), + Text( + 'ID ${suggestion.id} · ${suggestion.employeeNo}', + style: theme.textTheme.muted.copyWith( + fontSize: 12, + ), + ), + ], + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(suggestion.name, style: theme.textTheme.p), - const SizedBox(height: 4), - Text( - 'ID ${suggestion.id} · ${suggestion.employeeNo}', - style: theme.textTheme.muted.copyWith(fontSize: 12), - ), - ], - ), - ), - ); - }, + ); + }, + ), ), ), ), diff --git a/lib/features/inventory/shared/widgets/product_autocomplete_field.dart b/lib/features/inventory/shared/widgets/product_autocomplete_field.dart index 8b2577e..3751e13 100644 --- a/lib/features/inventory/shared/widgets/product_autocomplete_field.dart +++ b/lib/features/inventory/shared/widgets/product_autocomplete_field.dart @@ -239,6 +239,10 @@ class _InventoryProductAutocompleteFieldState placeholder: const Text('제품명 검색'), onChanged: (_) => widget.onChanged?.call(), onSubmitted: (_) => onFieldSubmitted(), + onPressedOutside: (event) { + // 포커스를 유지해 항목 선택 전 오버레이가 닫히지 않도록 한다. + focusNode.requestFocus(); + }, ); if (!_isSearching) { return input; @@ -260,12 +264,52 @@ class _InventoryProductAutocompleteFieldState }, optionsViewBuilder: (context, onSelected, options) { if (options.isEmpty) { - return Align( + return Listener( + onPointerDown: (_) { + if (!widget.productFocusNode.hasPrimaryFocus) { + widget.productFocusNode.requestFocus(); + } + }, + child: Align( + alignment: AlignmentDirectional.topStart, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: constraints.maxWidth, + maxHeight: 220, + ), + child: Material( + elevation: 6, + color: theme.colorScheme.background, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: theme.colorScheme.border), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Text( + '검색 결과가 없습니다.', + style: theme.textTheme.muted, + ), + ), + ), + ), + ), + ), + ); + } + return Listener( + onPointerDown: (_) { + if (!widget.productFocusNode.hasPrimaryFocus) { + widget.productFocusNode.requestFocus(); + } + }, + child: Align( alignment: AlignmentDirectional.topStart, child: ConstrainedBox( constraints: BoxConstraints( maxWidth: constraints.maxWidth, - maxHeight: 220, + maxHeight: 260, ), child: Material( elevation: 6, @@ -274,61 +318,35 @@ class _InventoryProductAutocompleteFieldState borderRadius: BorderRadius.circular(12), side: BorderSide(color: theme.colorScheme.border), ), - child: Center( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Text( - '검색 결과가 없습니다.', - style: theme.textTheme.muted, - ), - ), - ), - ), - ), - ); - } - return Align( - alignment: AlignmentDirectional.topStart, - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: constraints.maxWidth, - maxHeight: 260, - ), - child: Material( - elevation: 6, - color: theme.colorScheme.background, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - side: BorderSide(color: theme.colorScheme.border), - ), - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 6), - itemCount: options.length, - itemBuilder: (context, index) { - final option = options.elementAt(index); - return InkWell( - onTap: () => onSelected(option), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(option.name, style: theme.textTheme.p), - const SizedBox(height: 4), - Text( - '${option.code} · ${option.vendorName} · ${option.unitName}', - style: theme.textTheme.muted.copyWith( - fontSize: 12, + child: ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 6), + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + return InkWell( + onTap: () => onSelected(option), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(option.name, style: theme.textTheme.p), + const SizedBox(height: 4), + Text( + '${option.code} · ${option.vendorName} · ${option.unitName}', + style: theme.textTheme.muted.copyWith( + fontSize: 12, + ), ), - ), - ], + ], + ), ), - ), - ); - }, + ); + }, + ), ), ), ), diff --git a/lib/features/inventory/shared/widgets/warehouse_select_field.dart b/lib/features/inventory/shared/widgets/warehouse_select_field.dart index bf78f1d..0f60f5d 100644 --- a/lib/features/inventory/shared/widgets/warehouse_select_field.dart +++ b/lib/features/inventory/shared/widgets/warehouse_select_field.dart @@ -395,6 +395,10 @@ class _InventoryWarehouseSelectFieldState enabled: widget.enabled, readOnly: !widget.enabled, placeholder: placeholder, + onPressedOutside: (event) { + // 포커스를 유지하지 않으면 드롭다운이 즉시 닫히므로 재요청한다. + focusNode.requestFocus(); + }, ), if (_isSearching) const Padding( @@ -410,41 +414,60 @@ class _InventoryWarehouseSelectFieldState }, optionsViewBuilder: (context, onSelected, options) { if (options.isEmpty) { - return Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200, minWidth: 260), - child: ShadCard( - padding: const EdgeInsets.symmetric(vertical: 12), - child: const Center(child: Text('검색 결과가 없습니다.')), + return Listener( + onPointerDown: (_) { + if (!_focusNode.hasPrimaryFocus) { + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + minWidth: 260, + ), + child: ShadCard( + padding: const EdgeInsets.symmetric(vertical: 12), + child: const Center(child: Text('검색 결과가 없습니다.')), + ), ), ), ); } - return Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 240, minWidth: 260), - child: ShadCard( - padding: EdgeInsets.zero, - child: ListView.builder( - shrinkWrap: true, - itemCount: options.length, - itemBuilder: (context, index) { - final option = options.elementAt(index); - final isAll = option.id == -1; - final label = isAll ? widget.allLabel : _displayLabel(option); - return InkWell( - onTap: () => onSelected(option), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + return Listener( + onPointerDown: (_) { + if (!_focusNode.hasPrimaryFocus) { + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240, minWidth: 260), + child: ShadCard( + padding: EdgeInsets.zero, + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final option = options.elementAt(index); + final isAll = option.id == -1; + final label = isAll + ? widget.allLabel + : _displayLabel(option); + return InkWell( + onTap: () => onSelected(option), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Text(label), ), - child: Text(label), - ), - ); - }, + ); + }, + ), ), ), ), diff --git a/lib/features/masters/product/presentation/controllers/product_controller.dart b/lib/features/masters/product/presentation/controllers/product_controller.dart index 65b52b4..9a9e931 100644 --- a/lib/features/masters/product/presentation/controllers/product_controller.dart +++ b/lib/features/masters/product/presentation/controllers/product_controller.dart @@ -104,6 +104,7 @@ class ProductController extends ChangeNotifier { _isLoadingLookups = true; notifyListeners(); try { + debugPrint('[ProductController] 드롭다운 데이터 조회 시작'); final vendors = await fetchAllPaginatedItems( request: (page, pageSize) => _vendorRepository.list(page: page, pageSize: pageSize), @@ -114,9 +115,16 @@ class ProductController extends ChangeNotifier { ); _vendorOptions = vendors; _uomOptions = uoms; + debugPrint( + '[ProductController] 드롭다운 데이터 조회 완료 ' + '(vendors=${vendors.length}, uoms=${uoms.length})', + ); } catch (error) { final failure = Failure.from(error); _errorMessage = failure.describe(); + debugPrint( + '[ProductController] 드롭다운 데이터 조회 실패: ${_errorMessage ?? '알 수 없는 오류'}', + ); } finally { _isLoadingLookups = false; notifyListeners(); diff --git a/lib/features/masters/product/presentation/pages/product_page.dart b/lib/features/masters/product/presentation/pages/product_page.dart index 3335bf9..33fac2a 100644 --- a/lib/features/masters/product/presentation/pages/product_page.dart +++ b/lib/features/masters/product/presentation/pages/product_page.dart @@ -345,8 +345,8 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { size: ShadButtonSize.sm, onPressed: _controller.isLoading || currentPage >= totalPages - ? null - : () => _goToPage(totalPages), + ? null + : () => _goToPage(totalPages), child: const Text('마지막'), ), ], @@ -633,8 +633,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { return ShadButton.ghost( onPressed: isSaving ? null - : () => - Navigator.of(context, rootNavigator: true).pop(false), + : () => Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ); }, @@ -739,6 +738,9 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { enabled: !isSaving, placeholder: const Text('제조사를 선택하세요'), onSelected: (id) { + debugPrint( + '[ProductForm] 제조사 선택 -> id=$id', + ); vendorNotifier.value = id; vendorError.value = null; }, @@ -780,6 +782,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { enabled: !isSaving, placeholder: const Text('단위를 선택하세요'), onSelected: (id) { + debugPrint('[ProductForm] 단위 선택 -> id=$id'); uomNotifier.value = id; uomError.value = null; }, @@ -867,8 +870,7 @@ class _ProductEnabledPageState extends State<_ProductEnabledPage> { title: '제품 삭제', description: '"${product.productName}" 제품을 삭제하시겠습니까?', primaryAction: ShadButton.destructive( - onPressed: () => - Navigator.of(context, rootNavigator: true).pop(true), + onPressed: () => Navigator.of(context, rootNavigator: true).pop(true), child: const Text('삭제'), ), secondaryAction: ShadButton.ghost( diff --git a/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart b/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart index b80fb55..37845a9 100644 --- a/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart +++ b/lib/features/masters/product/presentation/widgets/uom_autocomplete_field.dart @@ -52,6 +52,7 @@ class _UomAutocompleteFieldState extends State { void initState() { super.initState(); _controller.addListener(_handleTextChanged); + _focusNode.addListener(_handleFocusChange); _initializeOptions(); } @@ -75,7 +76,9 @@ class _UomAutocompleteFieldState extends State { void dispose() { _controller.removeListener(_handleTextChanged); _controller.dispose(); - _focusNode.dispose(); + _focusNode + ..removeListener(_handleFocusChange) + ..dispose(); _debounce?.cancel(); super.dispose(); } @@ -96,6 +99,7 @@ class _UomAutocompleteFieldState extends State { ); _isApplyingText = false; } + _log('초기 옵션 준비 완료 (base=${_baseOptions.length})'); } void _resetSuggestions() { @@ -104,6 +108,7 @@ class _UomAutocompleteFieldState extends State { ..clear() ..addAll(_baseOptions); }); + _log('입력 초기화 -> 제안 목록 리셋 (count=${_suggestions.length})'); } void _handleTextChanged() { @@ -127,6 +132,7 @@ class _UomAutocompleteFieldState extends State { void _scheduleSearch(String keyword) { _debounce?.cancel(); _debounce = Timer(_debounceDuration, () => _search(keyword)); + _log('검색 예약: "$keyword"'); } Future _search(String keyword) async { @@ -143,6 +149,7 @@ class _UomAutocompleteFieldState extends State { _error = null; }); try { + _log('단위 검색 시작: "$keyword"'); final result = await repository.list( page: 1, pageSize: 20, @@ -159,6 +166,7 @@ class _UomAutocompleteFieldState extends State { ..addAll(items); _isSearching = false; }); + _log('단위 검색 완료: ${items.length}건'); } catch (error) { if (!mounted || request != _requestId) { return; @@ -169,6 +177,7 @@ class _UomAutocompleteFieldState extends State { _suggestions.clear(); _isSearching = false; }); + _log('단위 검색 실패: ${_error ?? '알 수 없는 오류'}'); } } @@ -208,6 +217,11 @@ class _UomAutocompleteFieldState extends State { } _applyControllerText(uom?.uomName ?? ''); }); + if (uom == null) { + _log('선택 해제됨'); + } else { + _log('항목 선택: id=${uom.id}, label=${uom.uomName}'); + } if (notify) { widget.onSelected(uom?.id); } @@ -249,6 +263,13 @@ class _UomAutocompleteFieldState extends State { enabled: widget.enabled, readOnly: !widget.enabled, placeholder: placeholder, + onPressedOutside: (event) { + _log( + '입력 외부 포인터 감지 -> 포커스 유지 시도 ' + 'pos=${event.localPosition} kind=${event.kind}', + ); + focusNode.requestFocus(); + }, ), if (_isSearching) const Padding( @@ -264,39 +285,75 @@ class _UomAutocompleteFieldState extends State { }, optionsViewBuilder: (context, onSelected, options) { if (options.isEmpty) { - return Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200, minWidth: 220), - child: ShadCard( - padding: const EdgeInsets.symmetric(vertical: 12), - child: const Center(child: Text('검색 결과가 없습니다.')), + return Listener( + onPointerDown: (event) { + _log( + '오버레이 포인터 다운 (빈 목록) ' + 'pos=${event.localPosition} kind=${event.kind}', + ); + if (!_focusNode.hasPrimaryFocus) { + _log('포커스 복구 시도 (빈 목록)'); + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + minWidth: 220, + ), + child: ShadCard( + padding: const EdgeInsets.symmetric(vertical: 12), + child: const Center(child: Text('검색 결과가 없습니다.')), + ), ), ), ); } - return Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 240, minWidth: 220), - child: ShadCard( - padding: EdgeInsets.zero, - child: ListView.builder( - shrinkWrap: true, - itemCount: options.length, - itemBuilder: (context, index) { - final uom = options.elementAt(index); - return InkWell( - onTap: () => onSelected(uom), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + _log('드롭다운 리스트 표시: ${options.length}건'); + return Listener( + onPointerDown: (event) { + _log( + '오버레이 포인터 다운 pos=${event.localPosition} ' + 'kind=${event.kind}', + ); + if (!_focusNode.hasPrimaryFocus) { + _log('포커스 복구 시도'); + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240, minWidth: 220), + child: ShadCard( + padding: EdgeInsets.zero, + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final uom = options.elementAt(index); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => + _log('항목 탭다운: id=${uom.id} index=$index'), + onTapCancel: () => + _log('항목 탭 취소: id=${uom.id} index=$index'), + onTap: () { + _log('항목 탭 -> onSelected 호출: id=${uom.id}'); + onSelected(uom); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Text(uom.uomName), ), - child: Text(uom.uomName), - ), - ); - }, + ); + }, + ), ), ), ), @@ -310,5 +367,18 @@ class _UomAutocompleteFieldState extends State { _controller.text = value; _controller.selection = TextSelection.collapsed(offset: value.length); _isApplyingText = false; + _log('입력 필드 텍스트 적용: "$value"'); + } + + void _handleFocusChange() { + if (_focusNode.hasFocus) { + _log('포커스 획득 -> 드롭다운 진입'); + } else { + _log('포커스 해제 -> 드롭다운 종료'); + } + } + + void _log(String message) { + debugPrint('[UomAutocomplete] $message'); } } diff --git a/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart b/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart index f4e8db4..204e40b 100644 --- a/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart +++ b/lib/features/masters/product/presentation/widgets/vendor_autocomplete_field.dart @@ -54,6 +54,7 @@ class _VendorAutocompleteFieldState extends State { void initState() { super.initState(); _controller.addListener(_handleTextChanged); + _focusNode.addListener(_handleFocusChange); _initializeOptions(); } @@ -80,7 +81,9 @@ class _VendorAutocompleteFieldState extends State { void dispose() { _controller.removeListener(_handleTextChanged); _controller.dispose(); - _focusNode.dispose(); + _focusNode + ..removeListener(_handleFocusChange) + ..dispose(); _debounce?.cancel(); super.dispose(); } @@ -104,6 +107,7 @@ class _VendorAutocompleteFieldState extends State { ); _isApplyingText = false; } + _log('초기 옵션 준비 완료 (base=${_baseOptions.length})'); } void _resetSuggestions() { @@ -112,6 +116,7 @@ class _VendorAutocompleteFieldState extends State { ..clear() ..addAll(_baseOptions); }); + _log('입력 초기화 -> 제안 목록 리셋 (count=${_suggestions.length})'); } void _handleTextChanged() { @@ -135,6 +140,7 @@ class _VendorAutocompleteFieldState extends State { void _scheduleSearch(String keyword) { _debounce?.cancel(); _debounce = Timer(_debounceDuration, () => _search(keyword)); + _log('검색 예약: "$keyword"'); } Future _search(String keyword) async { @@ -151,6 +157,7 @@ class _VendorAutocompleteFieldState extends State { _error = null; }); try { + _log('공급업체 검색 시작: "$keyword"'); final result = await repository.list( page: 1, pageSize: 20, @@ -167,6 +174,7 @@ class _VendorAutocompleteFieldState extends State { ..addAll(items); _isSearching = false; }); + _log('공급업체 검색 완료: ${items.length}건'); } catch (error) { if (!mounted || request != _requestId) { return; @@ -177,6 +185,7 @@ class _VendorAutocompleteFieldState extends State { _suggestions.clear(); _isSearching = false; }); + _log('공급업체 검색 실패: ${_error ?? '알 수 없는 오류'}'); } } @@ -220,6 +229,11 @@ class _VendorAutocompleteFieldState extends State { _applyControllerText(_displayLabel(vendor)); } }); + if (vendor == null) { + _log('선택 해제됨'); + } else { + _log('항목 선택: id=${vendor.id}, label=${_displayLabel(vendor)}'); + } if (notify) { widget.onSelected(vendor?.id); } @@ -266,6 +280,13 @@ class _VendorAutocompleteFieldState extends State { enabled: widget.enabled, readOnly: !widget.enabled, placeholder: placeholder, + onPressedOutside: (event) { + _log( + '입력 외부 포인터 감지 -> 포커스 유지 시도 ' + 'pos=${event.localPosition} kind=${event.kind}', + ); + focusNode.requestFocus(); + }, ), if (_isSearching) const Padding( @@ -281,39 +302,75 @@ class _VendorAutocompleteFieldState extends State { }, optionsViewBuilder: (context, onSelected, options) { if (options.isEmpty) { - return Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 200, minWidth: 260), - child: ShadCard( - padding: const EdgeInsets.symmetric(vertical: 12), - child: const Center(child: Text('검색 결과가 없습니다.')), + return Listener( + onPointerDown: (event) { + _log( + '오버레이 포인터 다운 (빈 목록) ' + 'pos=${event.localPosition} kind=${event.kind}', + ); + if (!_focusNode.hasPrimaryFocus) { + _log('포커스 복구 시도 (빈 목록)'); + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxHeight: 200, + minWidth: 260, + ), + child: ShadCard( + padding: const EdgeInsets.symmetric(vertical: 12), + child: const Center(child: Text('검색 결과가 없습니다.')), + ), ), ), ); } - return Align( - alignment: Alignment.topLeft, - child: ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 240, minWidth: 260), - child: ShadCard( - padding: EdgeInsets.zero, - child: ListView.builder( - shrinkWrap: true, - itemCount: options.length, - itemBuilder: (context, index) { - final vendor = options.elementAt(index); - return InkWell( - onTap: () => onSelected(vendor), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, + _log('드롭다운 리스트 표시: ${options.length}건'); + return Listener( + onPointerDown: (event) { + _log( + '오버레이 포인터 다운 pos=${event.localPosition} ' + 'kind=${event.kind}', + ); + if (!_focusNode.hasPrimaryFocus) { + _log('포커스 복구 시도'); + _focusNode.requestFocus(); + } + }, + child: Align( + alignment: Alignment.topLeft, + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 240, minWidth: 260), + child: ShadCard( + padding: EdgeInsets.zero, + child: ListView.builder( + shrinkWrap: true, + itemCount: options.length, + itemBuilder: (context, index) { + final vendor = options.elementAt(index); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTapDown: (_) => + _log('항목 탭다운: id=${vendor.id} index=$index'), + onTapCancel: () => + _log('항목 탭 취소: id=${vendor.id} index=$index'), + onTap: () { + _log('항목 탭 -> onSelected 호출: id=${vendor.id}'); + onSelected(vendor); + }, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Text(_displayLabel(vendor)), ), - child: Text(_displayLabel(vendor)), - ), - ); - }, + ); + }, + ), ), ), ), @@ -327,5 +384,18 @@ class _VendorAutocompleteFieldState extends State { _controller.text = value; _controller.selection = TextSelection.collapsed(offset: value.length); _isApplyingText = false; + _log('입력 필드 텍스트 적용: "$value"'); + } + + void _handleFocusChange() { + if (_focusNode.hasFocus) { + _log('포커스 획득 -> 드롭다운 진입'); + } else { + _log('포커스 해제 -> 드롭다운 종료'); + } + } + + void _log(String message) { + debugPrint('[VendorAutocomplete] $message'); } }