전역 구조 리팩터링 및 테스트 확장

This commit is contained in:
JiWoong Sul
2025-09-29 01:51:47 +09:00
parent c00c0c9ab2
commit fef7108479
70 changed files with 7709 additions and 3185 deletions

View File

@@ -0,0 +1,232 @@
import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
/// 인벤토리 폼에서 공유하는 제품 카탈로그 항목.
class InventoryProductCatalogItem {
const InventoryProductCatalogItem({
required this.code,
required this.name,
required this.manufacturer,
required this.unit,
});
final String code;
final String name;
final String manufacturer;
final String unit;
}
String _normalizeText(String value) {
return value.toLowerCase().replaceAll(RegExp(r'[^a-z0-9가-힣]'), '');
}
/// 제품 카탈로그 유틸리티.
class InventoryProductCatalog {
static final List<InventoryProductCatalogItem> items = List.unmodifiable([
const InventoryProductCatalogItem(
code: 'P-100',
name: 'XR-5000',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-101',
name: 'XR-5001',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-102',
name: 'Eco-200',
manufacturer: '그린텍',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-201',
name: 'Delta-One',
manufacturer: '델타',
unit: 'SET',
),
const InventoryProductCatalogItem(
code: 'P-210',
name: 'SmartGauge A1',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-305',
name: 'PowerPack Mini',
manufacturer: '에이치솔루션',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-320',
name: 'Hydra-Flow 2',
manufacturer: '블루하이드',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-401',
name: 'SolarEdge Pro',
manufacturer: '그린텍',
unit: 'EA',
),
const InventoryProductCatalogItem(
code: 'P-430',
name: 'Alpha-Kit 12',
manufacturer: '테크솔루션',
unit: 'SET',
),
const InventoryProductCatalogItem(
code: 'P-501',
name: 'LogiSense 5',
manufacturer: '슈퍼벤더',
unit: 'EA',
),
]);
static final Map<String, InventoryProductCatalogItem> _byKey = {
for (final item in items) _normalizeText(item.name): item,
};
static InventoryProductCatalogItem? match(String value) {
if (value.isEmpty) return null;
return _byKey[_normalizeText(value)];
}
static List<InventoryProductCatalogItem> filter(String query) {
final normalized = _normalizeText(query.trim());
if (normalized.isEmpty) {
return items.take(12).toList();
}
final lower = query.trim().toLowerCase();
return [
for (final item in items)
if (_normalizeText(item.name).contains(normalized) ||
item.code.toLowerCase().contains(lower))
item,
];
}
}
/// 고객 카탈로그 항목.
class InventoryCustomerCatalogItem {
const InventoryCustomerCatalogItem({
required this.code,
required this.name,
required this.industry,
required this.region,
});
final String code;
final String name;
final String industry;
final String region;
}
/// 고객 카탈로그 유틸리티.
class InventoryCustomerCatalog {
static final List<InventoryCustomerCatalogItem> items = List.unmodifiable([
const InventoryCustomerCatalogItem(
code: 'C-1001',
name: '슈퍼포트 파트너',
industry: '물류',
region: '서울',
),
const InventoryCustomerCatalogItem(
code: 'C-1002',
name: '그린에너지',
industry: '에너지',
region: '대전',
),
const InventoryCustomerCatalogItem(
code: 'C-1003',
name: '테크솔루션',
industry: 'IT 서비스',
region: '부산',
),
const InventoryCustomerCatalogItem(
code: 'C-1004',
name: '에이치솔루션',
industry: '제조',
region: '인천',
),
const InventoryCustomerCatalogItem(
code: 'C-1005',
name: '블루하이드',
industry: '해양장비',
region: '울산',
),
const InventoryCustomerCatalogItem(
code: 'C-1010',
name: '넥스트파워',
industry: '발전설비',
region: '광주',
),
const InventoryCustomerCatalogItem(
code: 'C-1011',
name: '씨에스테크',
industry: '반도체',
region: '수원',
),
const InventoryCustomerCatalogItem(
code: 'C-1012',
name: '알파시스템',
industry: '장비임대',
region: '대구',
),
const InventoryCustomerCatalogItem(
code: 'C-1013',
name: '스타트랩',
industry: '연구개발',
region: '세종',
),
const InventoryCustomerCatalogItem(
code: 'C-1014',
name: '메가스틸',
industry: '철강',
region: '포항',
),
]);
static final Map<String, InventoryCustomerCatalogItem> _byName = {
for (final item in items) item.name: item,
};
static InventoryCustomerCatalogItem? byName(String name) => _byName[name];
static List<InventoryCustomerCatalogItem> filter(String query) {
final normalized = _normalizeText(query.trim());
if (normalized.isEmpty) {
return items;
}
final lower = query.trim().toLowerCase();
return [
for (final item in items)
if (_normalizeText(item.name).contains(normalized) ||
item.code.toLowerCase().contains(lower) ||
_normalizeText(item.industry).contains(normalized) ||
_normalizeText(item.region).contains(normalized))
item,
];
}
static String displayLabel(String name) {
final item = byName(name);
if (item == null) {
return name;
}
return '${item.name} (${item.code})';
}
}
/// 검색 결과가 없을 때 노출할 기본 위젯.
Widget buildEmptySearchResult(
ShadTextTheme textTheme, {
String message = '검색 결과가 없습니다.',
}) {
return Padding(
padding: const EdgeInsets.all(12),
child: Text(message, style: textTheme.muted),
);
}

View File

@@ -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,
),
),
],
),
),
);
},
),
),
),
);
},
);
},
);
}
}