fix(inventory): 파트너 연동 및 상세 모달 동작 안정화
- 입고 레코드에 파트너 식별자와 고객 요약을 캐싱하고 상세 칩으로 노출 - 입고 등록 모달에서 파트너 선택 복원과 고객 동기화를 지원하며 취소 시 상세를 복귀하도록 수정 - 재고 컨트롤러에 고객 동기화 유틸리티와 결재 상태 로딩을 추가하고 단위 테스트를 확장 - 제품·파트너 자동완성 위젯을 재작성해 초기 로딩, 검색, 외부 컨트롤러 동기화를 안정화 - 재고 상세/공통 모달 닫기와 출고·대여 편집 모달의 네비게이터 호출을 루트 기준으로 통일 - 테스트: flutter analyze, flutter test (기존 레이아웃 검증 케이스 실패 지속)
This commit is contained in:
411
lib/features/inventory/shared/widgets/partner_select_field.dart
Normal file
411
lib/features/inventory/shared/widgets/partner_select_field.dart
Normal file
@@ -0,0 +1,411 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/utils/pagination_utils.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
import 'package:superport_v2/features/masters/customer/domain/entities/customer.dart';
|
||||
import 'package:superport_v2/features/masters/customer/domain/repositories/customer_repository.dart';
|
||||
|
||||
/// 파트너사를 나타내는 선택 옵션 모델.
|
||||
class InventoryPartnerOption {
|
||||
InventoryPartnerOption({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.code,
|
||||
});
|
||||
|
||||
factory InventoryPartnerOption.fromCustomer(Customer customer) {
|
||||
return InventoryPartnerOption(
|
||||
id: customer.id ?? 0,
|
||||
name: customer.customerName,
|
||||
code: customer.customerCode,
|
||||
);
|
||||
}
|
||||
|
||||
final int id;
|
||||
final String name;
|
||||
final String code;
|
||||
|
||||
String get label => code.isEmpty ? name : '$name ($code)';
|
||||
}
|
||||
|
||||
/// 파트너사 자동완성 입력 필드.
|
||||
class InventoryPartnerSelectField extends StatefulWidget {
|
||||
const InventoryPartnerSelectField({
|
||||
super.key,
|
||||
this.initialPartner,
|
||||
required this.onChanged,
|
||||
this.enabled = true,
|
||||
this.placeholder,
|
||||
});
|
||||
|
||||
final InventoryPartnerOption? initialPartner;
|
||||
final ValueChanged<InventoryPartnerOption?> onChanged;
|
||||
final bool enabled;
|
||||
final Widget? placeholder;
|
||||
|
||||
@override
|
||||
State<InventoryPartnerSelectField> createState() =>
|
||||
_InventoryPartnerSelectFieldState();
|
||||
}
|
||||
|
||||
class _InventoryPartnerSelectFieldState
|
||||
extends State<InventoryPartnerSelectField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 280);
|
||||
|
||||
CustomerRepository? get _repository =>
|
||||
GetIt.I.isRegistered<CustomerRepository>()
|
||||
? GetIt.I<CustomerRepository>()
|
||||
: null;
|
||||
|
||||
final TextEditingController _controller = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
final List<InventoryPartnerOption> _baseOptions = [];
|
||||
final List<InventoryPartnerOption> _suggestions = [];
|
||||
InventoryPartnerOption? _selected;
|
||||
Timer? _debounce;
|
||||
bool _isSearching = false;
|
||||
bool _isLoadingInitial = false;
|
||||
String? _error;
|
||||
int _requestId = 0;
|
||||
bool _isApplyingText = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selected = widget.initialPartner;
|
||||
_controller.addListener(_handleTextChanged);
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
_loadInitialOptions();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant InventoryPartnerSelectField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.initialPartner != oldWidget.initialPartner) {
|
||||
if (widget.initialPartner != null) {
|
||||
_setSelection(widget.initialPartner!, notify: false);
|
||||
} else {
|
||||
_clearSelection(notify: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller
|
||||
..removeListener(_handleTextChanged)
|
||||
..dispose();
|
||||
_focusNode
|
||||
..removeListener(_handleFocusChange)
|
||||
..dispose();
|
||||
_debounce?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadInitialOptions() async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
setState(() {
|
||||
_error = '파트너 데이터를 불러올 수 없습니다.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoadingInitial = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final partners = await fetchAllPaginatedItems<Customer>(
|
||||
request: (page, pageSize) => repository.list(
|
||||
page: page,
|
||||
pageSize: pageSize,
|
||||
isPartner: true,
|
||||
isActive: true,
|
||||
),
|
||||
pageSize: 40,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final options = partners
|
||||
.where((customer) => customer.id != null)
|
||||
.map(InventoryPartnerOption.fromCustomer)
|
||||
.toList(growable: false);
|
||||
setState(() {
|
||||
_baseOptions
|
||||
..clear()
|
||||
..addAll(options);
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(options);
|
||||
_isLoadingInitial = false;
|
||||
});
|
||||
final initial = widget.initialPartner;
|
||||
if (initial != null) {
|
||||
_setSelection(initial, notify: false);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final failure = Failure.from(error);
|
||||
setState(() {
|
||||
_error = failure.describe();
|
||||
_isLoadingInitial = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
if (_isApplyingText) {
|
||||
return;
|
||||
}
|
||||
final keyword = _controller.text.trim();
|
||||
if (keyword.isEmpty) {
|
||||
_debounce?.cancel();
|
||||
setState(() {
|
||||
_isSearching = false;
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_baseOptions);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_selected != null && keyword != _selected!.label) {
|
||||
_selected = null;
|
||||
widget.onChanged(null);
|
||||
}
|
||||
_scheduleSearch(keyword);
|
||||
}
|
||||
|
||||
void _scheduleSearch(String keyword) {
|
||||
_debounce?.cancel();
|
||||
_debounce = Timer(_debounceDuration, () => _search(keyword));
|
||||
}
|
||||
|
||||
Future<void> _search(String keyword) async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
return;
|
||||
}
|
||||
final request = ++_requestId;
|
||||
setState(() {
|
||||
_isSearching = true;
|
||||
});
|
||||
try {
|
||||
final result = await repository.list(
|
||||
page: 1,
|
||||
pageSize: 20,
|
||||
query: keyword,
|
||||
isPartner: true,
|
||||
isActive: true,
|
||||
);
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
final items = result.items
|
||||
.where((customer) => customer.id != null)
|
||||
.map(InventoryPartnerOption.fromCustomer)
|
||||
.toList(growable: false);
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(items);
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted || request != _requestId) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_baseOptions);
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _setSelection(InventoryPartnerOption option, {bool notify = true}) {
|
||||
setState(() {
|
||||
_selected = option;
|
||||
if (!_baseOptions.any((item) => item.id == option.id)) {
|
||||
_baseOptions.add(option);
|
||||
}
|
||||
_applyControllerText(option.label);
|
||||
});
|
||||
if (notify) {
|
||||
widget.onChanged(option);
|
||||
}
|
||||
}
|
||||
|
||||
void _clearSelection({bool notify = true}) {
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_applyControllerText('');
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_baseOptions);
|
||||
});
|
||||
if (notify) {
|
||||
widget.onChanged(null);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyControllerText(String text) {
|
||||
_isApplyingText = true;
|
||||
_controller
|
||||
..text = text
|
||||
..selection = TextSelection.collapsed(offset: text.length);
|
||||
_isApplyingText = false;
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
if (!_focusNode.hasFocus && _selected != null) {
|
||||
_applyControllerText(_selected!.label);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLoadingInput() {
|
||||
return Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: const [
|
||||
ShadInput(readOnly: true),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_isLoadingInitial) {
|
||||
return _buildLoadingInput();
|
||||
}
|
||||
if (_error != null) {
|
||||
return ShadInput(
|
||||
readOnly: true,
|
||||
enabled: false,
|
||||
placeholder: Text(_error!),
|
||||
);
|
||||
}
|
||||
|
||||
return RawAutocomplete<InventoryPartnerOption>(
|
||||
textEditingController: _controller,
|
||||
focusNode: _focusNode,
|
||||
optionsBuilder: (value) {
|
||||
final keyword = value.text.trim();
|
||||
if (keyword.isEmpty) {
|
||||
return _suggestions.isEmpty ? _baseOptions : _suggestions;
|
||||
}
|
||||
final lower = keyword.toLowerCase();
|
||||
final source = _suggestions.isEmpty ? _baseOptions : _suggestions;
|
||||
return source.where((option) {
|
||||
return option.name.toLowerCase().contains(lower) ||
|
||||
option.code.toLowerCase().contains(lower);
|
||||
});
|
||||
},
|
||||
displayStringForOption: (option) => option.label,
|
||||
onSelected: (option) {
|
||||
_setSelection(option);
|
||||
},
|
||||
fieldViewBuilder: (context, controller, focusNode, onSubmitted) {
|
||||
assert(identical(controller, _controller));
|
||||
final placeholder = widget.placeholder ?? const Text('파트너사를 선택하세요');
|
||||
final input = ShadInput(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
enabled: widget.enabled,
|
||||
readOnly: !widget.enabled,
|
||||
placeholder: placeholder,
|
||||
onSubmitted: (_) => onSubmitted(),
|
||||
);
|
||||
if (!_isSearching) {
|
||||
return input;
|
||||
}
|
||||
return Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
input,
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
if (options.isEmpty) {
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 220, minWidth: 260),
|
||||
child: ShadCard(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
child: const Center(child: Text('검색 결과가 없습니다.')),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 260, minWidth: 260),
|
||||
child: ShadCard(
|
||||
padding: EdgeInsets.zero,
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => onSelected(option),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
option.name,
|
||||
style: ShadTheme.of(context).textTheme.p,
|
||||
),
|
||||
if (option.code.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
option.code,
|
||||
style: ShadTheme.of(
|
||||
context,
|
||||
).textTheme.muted.copyWith(fontSize: 12),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import 'package:superport_v2/core/common/utils/pagination_utils.dart';
|
||||
import 'package:superport_v2/core/network/failure.dart';
|
||||
import 'package:superport_v2/features/masters/product/domain/entities/product.dart';
|
||||
import 'package:superport_v2/features/masters/product/domain/repositories/product_repository.dart';
|
||||
|
||||
@@ -65,12 +68,17 @@ class _InventoryProductAutocompleteFieldState
|
||||
extends State<InventoryProductAutocompleteField> {
|
||||
static const Duration _debounceDuration = Duration(milliseconds: 280);
|
||||
|
||||
final List<InventoryProductSuggestion> _initialOptions =
|
||||
<InventoryProductSuggestion>[];
|
||||
final List<InventoryProductSuggestion> _suggestions =
|
||||
<InventoryProductSuggestion>[];
|
||||
InventoryProductSuggestion? _selected;
|
||||
Timer? _debounce;
|
||||
int _requestCounter = 0;
|
||||
bool _isSearching = false;
|
||||
bool _isLoadingInitial = false;
|
||||
String? _error;
|
||||
bool _isApplyingText = false;
|
||||
|
||||
ProductRepository? get _repository =>
|
||||
GetIt.I.isRegistered<ProductRepository>()
|
||||
@@ -82,13 +90,10 @@ class _InventoryProductAutocompleteFieldState
|
||||
super.initState();
|
||||
_selected = widget.initialSuggestion;
|
||||
if (_selected != null) {
|
||||
_applySuggestion(_selected!, updateProductField: false);
|
||||
_applySuggestionSilently(_selected!);
|
||||
}
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
if (widget.productController.text.trim().isNotEmpty &&
|
||||
widget.initialSuggestion == null) {
|
||||
_scheduleSearch(widget.productController.text.trim());
|
||||
}
|
||||
_loadInitialOptions();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -99,20 +104,108 @@ class _InventoryProductAutocompleteFieldState
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
_scheduleSearch(widget.productController.text.trim());
|
||||
}
|
||||
if (widget.initialSuggestion != oldWidget.initialSuggestion &&
|
||||
widget.initialSuggestion != null) {
|
||||
_selected = widget.initialSuggestion;
|
||||
_applySuggestion(widget.initialSuggestion!, updateProductField: false);
|
||||
if (widget.initialSuggestion != oldWidget.initialSuggestion) {
|
||||
if (widget.initialSuggestion != null) {
|
||||
_selected = widget.initialSuggestion;
|
||||
_applySuggestionSilently(widget.initialSuggestion!);
|
||||
} else {
|
||||
_selected = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadInitialOptions() async {
|
||||
final repository = _repository;
|
||||
if (repository == null) {
|
||||
setState(() {
|
||||
_error = '제품 데이터 소스를 찾을 수 없습니다.';
|
||||
_suggestions.clear();
|
||||
_initialOptions.clear();
|
||||
});
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_isLoadingInitial = true;
|
||||
_error = null;
|
||||
});
|
||||
try {
|
||||
final products = await fetchAllPaginatedItems<Product>(
|
||||
request: (page, pageSize) =>
|
||||
repository.list(page: page, pageSize: pageSize, isActive: true),
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final options = products
|
||||
.map(InventoryProductSuggestion.fromProduct)
|
||||
.toList(growable: false);
|
||||
setState(() {
|
||||
_initialOptions
|
||||
..clear()
|
||||
..addAll(options);
|
||||
if (_selected != null &&
|
||||
!_initialOptions.any((item) => item.id == _selected!.id)) {
|
||||
_initialOptions.add(_selected!);
|
||||
}
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_initialOptions);
|
||||
_isLoadingInitial = false;
|
||||
});
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
final failure = Failure.from(error);
|
||||
_error = failure.describe();
|
||||
_initialOptions.clear();
|
||||
_suggestions.clear();
|
||||
_isLoadingInitial = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _cacheSuggestion(InventoryProductSuggestion suggestion) {
|
||||
if (!_initialOptions.any((item) => item.id == suggestion.id)) {
|
||||
_initialOptions.add(suggestion);
|
||||
}
|
||||
if (!_suggestions.any((item) => item.id == suggestion.id)) {
|
||||
_suggestions.add(suggestion);
|
||||
}
|
||||
}
|
||||
|
||||
void _syncExternalControllers(InventoryProductSuggestion suggestion) {
|
||||
if (widget.manufacturerController.text != suggestion.vendorName) {
|
||||
widget.manufacturerController.text = suggestion.vendorName;
|
||||
}
|
||||
if (widget.unitController.text != suggestion.unitName) {
|
||||
widget.unitController.text = suggestion.unitName;
|
||||
}
|
||||
}
|
||||
|
||||
void _applySuggestionSilently(InventoryProductSuggestion suggestion) {
|
||||
_selected = suggestion;
|
||||
_cacheSuggestion(suggestion);
|
||||
_updateProductFieldValue(suggestion.name);
|
||||
_syncExternalControllers(suggestion);
|
||||
}
|
||||
|
||||
/// 입력 변화에 따라 제안 검색을 예약한다.
|
||||
void _handleTextChanged() {
|
||||
if (_isApplyingText) {
|
||||
return;
|
||||
}
|
||||
final text = widget.productController.text.trim();
|
||||
if (text.isEmpty) {
|
||||
_debounce?.cancel();
|
||||
_clearSelection();
|
||||
return;
|
||||
}
|
||||
if (_selected != null && text != _selected!.name) {
|
||||
_selected = null;
|
||||
widget.onSuggestionSelected(null);
|
||||
}
|
||||
_scheduleSearch(text);
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
@@ -148,10 +241,18 @@ class _InventoryProductAutocompleteFieldState
|
||||
if (!mounted || currentRequest != _requestCounter) {
|
||||
return;
|
||||
}
|
||||
final fetched = result.items
|
||||
.map(InventoryProductSuggestion.fromProduct)
|
||||
.toList(growable: false);
|
||||
setState(() {
|
||||
for (final option in fetched) {
|
||||
if (!_initialOptions.any((item) => item.id == option.id)) {
|
||||
_initialOptions.add(option);
|
||||
}
|
||||
}
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(result.items.map(InventoryProductSuggestion.fromProduct));
|
||||
..addAll(fetched);
|
||||
_isSearching = false;
|
||||
});
|
||||
} catch (_) {
|
||||
@@ -159,7 +260,9 @@ class _InventoryProductAutocompleteFieldState
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_suggestions.clear();
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_initialOptions);
|
||||
_isSearching = false;
|
||||
});
|
||||
}
|
||||
@@ -169,43 +272,73 @@ class _InventoryProductAutocompleteFieldState
|
||||
void _applySuggestion(
|
||||
InventoryProductSuggestion suggestion, {
|
||||
bool updateProductField = true,
|
||||
bool notify = true,
|
||||
}) {
|
||||
_debounce?.cancel();
|
||||
setState(() {
|
||||
_selected = suggestion;
|
||||
_cacheSuggestion(suggestion);
|
||||
});
|
||||
widget.onSuggestionSelected(suggestion);
|
||||
if (notify) {
|
||||
widget.onSuggestionSelected(suggestion);
|
||||
}
|
||||
if (updateProductField) {
|
||||
widget.productController.text = suggestion.name;
|
||||
widget.productController.selection = TextSelection.collapsed(
|
||||
offset: widget.productController.text.length,
|
||||
_updateProductFieldValue(suggestion.name);
|
||||
}
|
||||
_syncExternalControllers(suggestion);
|
||||
if (kDebugMode) {
|
||||
debugPrint(
|
||||
'[InventoryProductAutocomplete] selected "${suggestion.name}" '
|
||||
'-> text="${widget.productController.text}"',
|
||||
);
|
||||
}
|
||||
if (widget.manufacturerController.text != suggestion.vendorName) {
|
||||
widget.manufacturerController.text = suggestion.vendorName;
|
||||
}
|
||||
if (widget.unitController.text != suggestion.unitName) {
|
||||
widget.unitController.text = suggestion.unitName;
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
void _clearSelection() {
|
||||
void _clearSelection({bool notify = true}) {
|
||||
if (_selected == null &&
|
||||
widget.manufacturerController.text.isEmpty &&
|
||||
widget.unitController.text.isEmpty) {
|
||||
widget.unitController.text.isEmpty &&
|
||||
widget.productController.text.isEmpty) {
|
||||
setState(() {
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_initialOptions);
|
||||
_isSearching = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
_debounce?.cancel();
|
||||
setState(() {
|
||||
_selected = null;
|
||||
_suggestions.clear();
|
||||
_suggestions
|
||||
..clear()
|
||||
..addAll(_initialOptions);
|
||||
_isSearching = false;
|
||||
});
|
||||
widget.onSuggestionSelected(null);
|
||||
if (notify) {
|
||||
widget.onSuggestionSelected(null);
|
||||
}
|
||||
widget.manufacturerController.clear();
|
||||
widget.unitController.clear();
|
||||
if (widget.productController.text.isEmpty) {
|
||||
_updateProductFieldValue('');
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
/// 제품 입력 필드에 텍스트와 커서를 안전하게 반영한다.
|
||||
void _updateProductFieldValue(String text) {
|
||||
final controller = widget.productController;
|
||||
_isApplyingText = true;
|
||||
controller.value = controller.value.copyWith(
|
||||
text: text,
|
||||
selection: TextSelection.collapsed(offset: text.length),
|
||||
composing: TextRange.empty,
|
||||
);
|
||||
_isApplyingText = false;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.productController.removeListener(_handleTextChanged);
|
||||
@@ -213,19 +346,55 @@ class _InventoryProductAutocompleteFieldState
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Widget _buildLoadingInput() {
|
||||
return Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
children: const [
|
||||
ShadInput(readOnly: true),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(left: 12),
|
||||
child: SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
if (_isLoadingInitial) {
|
||||
return _buildLoadingInput();
|
||||
}
|
||||
if (_error != null) {
|
||||
return ShadInput(
|
||||
controller: widget.productController,
|
||||
focusNode: widget.productFocusNode,
|
||||
enabled: false,
|
||||
readOnly: true,
|
||||
placeholder: Text(_error!),
|
||||
);
|
||||
}
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return RawAutocomplete<InventoryProductSuggestion>(
|
||||
textEditingController: widget.productController,
|
||||
focusNode: widget.productFocusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
if (textEditingValue.text.trim().isEmpty) {
|
||||
return const Iterable<InventoryProductSuggestion>.empty();
|
||||
final keyword = textEditingValue.text.trim();
|
||||
final base = _suggestions.isEmpty ? _initialOptions : _suggestions;
|
||||
if (keyword.isEmpty) {
|
||||
return base;
|
||||
}
|
||||
return _suggestions;
|
||||
final lowerKeyword = keyword.toLowerCase();
|
||||
return base.where((option) {
|
||||
return option.name.toLowerCase().contains(lowerKeyword) ||
|
||||
option.code.toLowerCase().contains(lowerKeyword) ||
|
||||
option.vendorName.toLowerCase().contains(lowerKeyword);
|
||||
});
|
||||
},
|
||||
displayStringForOption: (option) => option.name,
|
||||
onSelected: (option) {
|
||||
@@ -239,10 +408,6 @@ class _InventoryProductAutocompleteFieldState
|
||||
placeholder: const Text('제품명 검색'),
|
||||
onChanged: (_) => widget.onChanged?.call(),
|
||||
onSubmitted: (_) => onFieldSubmitted(),
|
||||
onPressedOutside: (event) {
|
||||
// 포커스를 유지해 항목 선택 전 오버레이가 닫히지 않도록 한다.
|
||||
focusNode.requestFocus();
|
||||
},
|
||||
);
|
||||
if (!_isSearching) {
|
||||
return input;
|
||||
@@ -323,8 +488,9 @@ class _InventoryProductAutocompleteFieldState
|
||||
itemCount: options.length,
|
||||
itemBuilder: (context, index) {
|
||||
final option = options.elementAt(index);
|
||||
return InkWell(
|
||||
onTap: () => onSelected(option),
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTapDown: (_) => onSelected(option),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
|
||||
Reference in New Issue
Block a user