전역 구조 리팩터링 및 테스트 확장
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../catalogs.dart';
|
||||
|
||||
/// 제품명 입력 시 카탈로그 자동완성을 제공하는 필드.
|
||||
class InventoryProductAutocompleteField extends StatefulWidget {
|
||||
const InventoryProductAutocompleteField({
|
||||
super.key,
|
||||
required this.productController,
|
||||
required this.productFocusNode,
|
||||
required this.manufacturerController,
|
||||
required this.unitController,
|
||||
required this.onCatalogMatched,
|
||||
this.onChanged,
|
||||
});
|
||||
|
||||
final TextEditingController productController;
|
||||
final FocusNode productFocusNode;
|
||||
final TextEditingController manufacturerController;
|
||||
final TextEditingController unitController;
|
||||
final ValueChanged<InventoryProductCatalogItem?> onCatalogMatched;
|
||||
final VoidCallback? onChanged;
|
||||
|
||||
@override
|
||||
State<InventoryProductAutocompleteField> createState() =>
|
||||
_InventoryProductAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _InventoryProductAutocompleteFieldState
|
||||
extends State<InventoryProductAutocompleteField> {
|
||||
InventoryProductCatalogItem? _catalogMatch;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
}
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant InventoryProductAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!identical(oldWidget.productController, widget.productController)) {
|
||||
oldWidget.productController.removeListener(_handleTextChanged);
|
||||
widget.productController.addListener(_handleTextChanged);
|
||||
_catalogMatch = InventoryProductCatalog.match(
|
||||
widget.productController.text.trim(),
|
||||
);
|
||||
if (_catalogMatch != null) {
|
||||
_applyCatalog(_catalogMatch!, updateProduct: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
final text = widget.productController.text.trim();
|
||||
final match = InventoryProductCatalog.match(text);
|
||||
if (match != null) {
|
||||
_applyCatalog(match);
|
||||
return;
|
||||
}
|
||||
if (_catalogMatch != null) {
|
||||
setState(() {
|
||||
_catalogMatch = null;
|
||||
});
|
||||
widget.onCatalogMatched(null);
|
||||
if (widget.manufacturerController.text.isNotEmpty) {
|
||||
widget.manufacturerController.clear();
|
||||
}
|
||||
if (widget.unitController.text.isNotEmpty) {
|
||||
widget.unitController.clear();
|
||||
}
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
void _applyCatalog(
|
||||
InventoryProductCatalogItem match, {
|
||||
bool updateProduct = true,
|
||||
}) {
|
||||
setState(() {
|
||||
_catalogMatch = match;
|
||||
});
|
||||
widget.onCatalogMatched(match);
|
||||
if (updateProduct && widget.productController.text != match.name) {
|
||||
widget.productController.text = match.name;
|
||||
widget.productController.selection = TextSelection.collapsed(
|
||||
offset: widget.productController.text.length,
|
||||
);
|
||||
}
|
||||
if (widget.manufacturerController.text != match.manufacturer) {
|
||||
widget.manufacturerController.text = match.manufacturer;
|
||||
}
|
||||
if (widget.unitController.text != match.unit) {
|
||||
widget.unitController.text = match.unit;
|
||||
}
|
||||
widget.onChanged?.call();
|
||||
}
|
||||
|
||||
Iterable<InventoryProductCatalogItem> _options(String query) {
|
||||
return InventoryProductCatalog.filter(query);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.productController.removeListener(_handleTextChanged);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return RawAutocomplete<InventoryProductCatalogItem>(
|
||||
textEditingController: widget.productController,
|
||||
focusNode: widget.productFocusNode,
|
||||
optionsBuilder: (textEditingValue) {
|
||||
return _options(textEditingValue.text);
|
||||
},
|
||||
displayStringForOption: (option) => option.name,
|
||||
onSelected: (option) {
|
||||
_applyCatalog(option);
|
||||
},
|
||||
fieldViewBuilder:
|
||||
(context, textEditingController, focusNode, onFieldSubmitted) {
|
||||
return ShadInput(
|
||||
controller: textEditingController,
|
||||
focusNode: focusNode,
|
||||
placeholder: const Text('제품명'),
|
||||
onChanged: (_) => widget.onChanged?.call(),
|
||||
onSubmitted: (_) => onFieldSubmitted(),
|
||||
);
|
||||
},
|
||||
optionsViewBuilder: (context, onSelected, options) {
|
||||
if (options.isEmpty) {
|
||||
return Align(
|
||||
alignment: AlignmentDirectional.topStart,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth,
|
||||
maxHeight: 240,
|
||||
),
|
||||
child: Material(
|
||||
elevation: 6,
|
||||
color: theme.colorScheme.background,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
side: BorderSide(color: theme.colorScheme.border),
|
||||
),
|
||||
child: buildEmptySearchResult(theme.textTheme),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
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.manufacturer} · ${option.unit}',
|
||||
style: theme.textTheme.muted.copyWith(
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user