Files
superport/lib/screens/equipment/equipment_in_form.dart
JiWoong Sul e7860ae028
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled
feat: 소프트 딜리트 기능 전면 구현 완료
## 주요 변경사항
- Company, Equipment, License, Warehouse Location 모든 화면에 소프트 딜리트 구현
- 관리자 권한으로 삭제된 데이터 조회 가능 (includeInactive 파라미터)
- 데이터 무결성 보장을 위한 논리 삭제 시스템 완성

## 기능 개선
- 각 리스트 컨트롤러에 toggleIncludeInactive() 메서드 추가
- UI에 "비활성 포함" 체크박스 추가 (관리자 전용)
- API 데이터소스에 includeInactive 파라미터 지원

## 문서 정리
- 불필요한 문서 파일 제거 및 재구성
- CLAUDE.md 프로젝트 상태 업데이트 (진행률 80%)
- 테스트 결과 문서화 (test20250812v01.md)

## UI 컴포넌트
- Equipment 화면 위젯 모듈화 (custom_dropdown_field, equipment_basic_info_section)
- 폼 유효성 검증 강화

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-12 20:02:54 +09:00

2401 lines
113 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
// import 'package:superport/models/equipment_unified_model.dart';
// import 'package:superport/screens/common/custom_widgets.dart' hide FormFieldWrapper;
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/templates/form_layout_template.dart';
import 'package:superport/utils/constants.dart';
// import 'package:flutter_localizations/flutter_localizations.dart';
// import 'package:superport/screens/equipment/widgets/autocomplete_text_field.dart';
import 'controllers/equipment_in_form_controller.dart';
// import 'package:superport/screens/common/widgets/category_autocomplete_field.dart';
// import 'package:superport/screens/common/widgets/autocomplete_dropdown_field.dart';
import 'package:superport/screens/common/widgets/remark_input.dart';
class EquipmentInFormScreen extends StatefulWidget {
final int? equipmentInId;
const EquipmentInFormScreen({super.key, this.equipmentInId});
@override
State<EquipmentInFormScreen> createState() => _EquipmentInFormScreenState();
}
class _EquipmentInFormScreenState extends State<EquipmentInFormScreen> {
late EquipmentInFormController _controller;
late FocusNode _manufacturerFocusNode;
late FocusNode _nameFieldFocusNode;
// 구매처 드롭다운 오버레이 관련
final LayerLink _partnerLayerLink = LayerLink();
OverlayEntry? _partnerOverlayEntry;
final FocusNode _partnerFocusNode = FocusNode();
late TextEditingController _partnerController;
// 입고지 드롭다운 오버레이 관련
final LayerLink _warehouseLayerLink = LayerLink();
OverlayEntry? _warehouseOverlayEntry;
final FocusNode _warehouseFocusNode = FocusNode();
late TextEditingController _warehouseController;
// 제조사 드롭다운 오버레이 관련
final LayerLink _manufacturerLayerLink = LayerLink();
OverlayEntry? _manufacturerOverlayEntry;
late TextEditingController _manufacturerController;
// 장비명 드롭다운 오버레이 관련
final LayerLink _equipmentNameLayerLink = LayerLink();
OverlayEntry? _equipmentNameOverlayEntry;
late TextEditingController _equipmentNameController;
// 대분류 드롭다운 오버레이 관련
final LayerLink _categoryLayerLink = LayerLink();
OverlayEntry? _categoryOverlayEntry;
final FocusNode _categoryFocusNode = FocusNode();
late TextEditingController _categoryController;
// 중분류 드롭다운 오버레이 관련
final LayerLink _subCategoryLayerLink = LayerLink();
OverlayEntry? _subCategoryOverlayEntry;
final FocusNode _subCategoryFocusNode = FocusNode();
late TextEditingController _subCategoryController;
// 소분류 드롭다운 오버레이 관련
final LayerLink _subSubCategoryLayerLink = LayerLink();
OverlayEntry? _subSubCategoryOverlayEntry;
final FocusNode _subSubCategoryFocusNode = FocusNode();
late TextEditingController _subSubCategoryController;
// 추가 필드 컨트롤러들
late TextEditingController _nameController;
late TextEditingController _serialNumberController;
late TextEditingController _barcodeController;
late TextEditingController _quantityController;
late TextEditingController _warrantyCodeController;
// 프로그램적 입력란 변경 여부 플래그
bool _isProgrammaticPartnerChange = false;
bool _isProgrammaticWarehouseChange = false;
bool _isProgrammaticManufacturerChange = false;
bool _isProgrammaticEquipmentNameChange = false;
bool _isProgrammaticCategoryChange = false;
bool _isProgrammaticSubCategoryChange = false;
bool _isProgrammaticSubSubCategoryChange = false;
// 입력란의 정확한 위치를 위한 GlobalKey
final GlobalKey _partnerFieldKey = GlobalKey();
final GlobalKey _warehouseFieldKey = GlobalKey();
final GlobalKey _manufacturerFieldKey = GlobalKey();
final GlobalKey _equipmentNameFieldKey = GlobalKey();
final GlobalKey _categoryFieldKey = GlobalKey();
final GlobalKey _subCategoryFieldKey = GlobalKey();
final GlobalKey _subSubCategoryFieldKey = GlobalKey();
// 자동완성 후보(입력값과 가장 근접한 파트너사) 계산 함수
String? _getAutocompleteSuggestion(String input) {
if (input.isEmpty) return null;
// 입력값으로 시작하는 후보 중 가장 짧은 것
final lower = input.toLowerCase();
final match = _controller.partnerCompanies.firstWhere(
(c) => c.toLowerCase().startsWith(lower),
orElse: () => '',
);
return match.isNotEmpty && match.length > input.length ? match : null;
}
// 자동완성 후보(입력값과 가장 근접한 입고지) 계산 함수
String? _getWarehouseAutocompleteSuggestion(String input) {
if (input.isEmpty) return null;
// 입력값으로 시작하는 후보 중 가장 짧은 것
final lower = input.toLowerCase();
final match = _controller.warehouseLocations.firstWhere(
(c) => c.toLowerCase().startsWith(lower),
orElse: () => '',
);
return match.isNotEmpty && match.length > input.length ? match : null;
}
// 자동완성 후보(입력값과 가장 근접한 제조사) 계산 함수
String? _getManufacturerAutocompleteSuggestion(String input) {
if (input.isEmpty) return null;
// 입력값으로 시작하는 후보 중 가장 짧은 것
final lower = input.toLowerCase();
final match = _controller.manufacturers.firstWhere(
(c) => c.toLowerCase().startsWith(lower),
orElse: () => '',
);
return match.isNotEmpty && match.length > input.length ? match : null;
}
// 자동완성 후보(입력값과 가장 근접한 장비명) 계산 함수
String? _getEquipmentNameAutocompleteSuggestion(String input) {
if (input.isEmpty) return null;
// 입력값으로 시작하는 후보 중 가장 짧은 것
final lower = input.toLowerCase();
final match = _controller.equipmentNames.firstWhere(
(c) => c.toLowerCase().startsWith(lower),
orElse: () => '',
);
return match.isNotEmpty && match.length > input.length ? match : null;
}
// 자동완성 후보(입력값과 가장 근접한 대분류) 계산 함수
String? _getCategoryAutocompleteSuggestion(String input) {
if (input.isEmpty) return null;
// 입력값으로 시작하는 후보 중 가장 짧은 것
final lower = input.toLowerCase();
final match = _controller.categories.firstWhere(
(c) => c.toLowerCase().startsWith(lower),
orElse: () => '',
);
return match.isNotEmpty && match.length > input.length ? match : null;
}
// 자동완성 후보(입력값과 가장 근접한 중분류) 계산 함수
String? _getSubCategoryAutocompleteSuggestion(String input) {
if (input.isEmpty) return null;
// 입력값으로 시작하는 후보 중 가장 짧은 것
final lower = input.toLowerCase();
final match = _controller.subCategories.firstWhere(
(c) => c.toLowerCase().startsWith(lower),
orElse: () => '',
);
return match.isNotEmpty && match.length > input.length ? match : null;
}
// 자동완성 후보(입력값과 가장 근접한 소분류) 계산 함수
String? _getSubSubCategoryAutocompleteSuggestion(String input) {
if (input.isEmpty) return null;
// 입력값으로 시작하는 후보 중 가장 짧은 것
final lower = input.toLowerCase();
final match = _controller.subSubCategories.firstWhere(
(c) => c.toLowerCase().startsWith(lower),
orElse: () => '',
);
return match.isNotEmpty && match.length > input.length ? match : null;
}
@override
void initState() {
super.initState();
_controller = EquipmentInFormController(
equipmentInId: widget.equipmentInId,
);
print('DEBUG: initState - equipmentInId: ${widget.equipmentInId}, isEditMode: ${_controller.isEditMode}');
// 컨트롤러 변경 리스너 추가 (데이터 로드 전에 추가해야 변경사항을 감지할 수 있음)
_controller.addListener(_onControllerUpdated);
// 수정 모드일 때 데이터 로드
if (_controller.isEditMode) {
print('DEBUG: Edit mode detected, loading equipment data...');
WidgetsBinding.instance.addPostFrameCallback((_) async {
await _controller.initializeForEdit();
print('DEBUG: Equipment data loaded, calling _updateTextControllers directly');
// 데이터 로드 후 직접 UI 업데이트 호출
if (mounted) {
_updateTextControllers();
}
});
}
_manufacturerFocusNode = FocusNode();
_nameFieldFocusNode = FocusNode();
// 컨트롤러들을 빈 값으로 초기화 (나중에 데이터 로드 시 업데이트됨)
_partnerController = TextEditingController();
_warehouseController = TextEditingController();
_manufacturerController = TextEditingController();
_equipmentNameController = TextEditingController();
_categoryController = TextEditingController();
_subCategoryController = TextEditingController();
_subSubCategoryController = TextEditingController();
_nameController = TextEditingController();
_serialNumberController = TextEditingController();
_barcodeController = TextEditingController();
_quantityController = TextEditingController(text: '1');
_warrantyCodeController = TextEditingController();
// 포커스 변경 리스너 추가
_partnerFocusNode.addListener(_onPartnerFocusChange);
_warehouseFocusNode.addListener(_onWarehouseFocusChange);
_manufacturerFocusNode.addListener(_onManufacturerFocusChange);
_nameFieldFocusNode.addListener(_onNameFieldFocusChange);
_categoryFocusNode.addListener(_onCategoryFocusChange);
_subCategoryFocusNode.addListener(_onSubCategoryFocusChange);
_subSubCategoryFocusNode.addListener(_onSubSubCategoryFocusChange);
}
// 컨트롤러 데이터 변경 시 텍스트 컨트롤러 업데이트
void _onControllerUpdated() {
print('DEBUG [_onControllerUpdated] Called - isEditMode: ${_controller.isEditMode}, isLoading: ${_controller.isLoading}, actualEquipmentId: ${_controller.actualEquipmentId}');
// 데이터 로딩이 완료되고 수정 모드일 때 텍스트 컨트롤러 업데이트
// actualEquipmentId가 설정되었다는 것은 데이터가 로드되었다는 의미
if (_controller.isEditMode && !_controller.isLoading && _controller.actualEquipmentId != null) {
print('DEBUG [_onControllerUpdated] Condition met, updating text controllers');
print('DEBUG [_onControllerUpdated] manufacturer: "${_controller.manufacturer}", name: "${_controller.name}"');
_updateTextControllers();
}
}
// 텍스트 컨트롤러 업데이트 메서드
void _updateTextControllers() {
print('DEBUG [_updateTextControllers] Called');
print('DEBUG [_updateTextControllers] Before update:');
print(' manufacturerController.text="${_manufacturerController.text}"');
print(' nameController.text="${_nameController.text}"');
print('DEBUG [_updateTextControllers] Controller values:');
print(' controller.manufacturer="${_controller.manufacturer}"');
print(' controller.name="${_controller.name}"');
print(' controller.serialNumber="${_controller.serialNumber}"');
print(' controller.quantity=${_controller.quantity}');
setState(() {
_manufacturerController.text = _controller.manufacturer;
_nameController.text = _controller.name;
_equipmentNameController.text = _controller.name; // 장비명 컨트롤러 추가
_categoryController.text = _controller.category;
_subCategoryController.text = _controller.subCategory;
_subSubCategoryController.text = _controller.subSubCategory;
_serialNumberController.text = _controller.serialNumber;
_barcodeController.text = _controller.barcode;
_quantityController.text = _controller.quantity.toString();
_warehouseController.text = _controller.warehouseLocation ?? '';
_partnerController.text = _controller.partnerCompany ?? '';
_warrantyCodeController.text = _controller.warrantyCode ?? '';
_controller.remarkController.text = _controller.remarkController.text;
});
print('DEBUG [_updateTextControllers] After update:');
print(' manufacturerController.text="${_manufacturerController.text}"');
print(' nameController.text="${_nameController.text}"');
}
@override
void dispose() {
_controller.removeListener(_onControllerUpdated);
_manufacturerFocusNode.dispose();
_nameFieldFocusNode.dispose();
_partnerOverlayEntry?.remove();
_partnerFocusNode.dispose();
_partnerController.dispose();
// 추가 리소스 정리
_warehouseOverlayEntry?.remove();
_warehouseFocusNode.dispose();
_warehouseController.dispose();
_manufacturerOverlayEntry?.remove();
_manufacturerController.dispose();
_equipmentNameOverlayEntry?.remove();
_equipmentNameController.dispose();
_categoryOverlayEntry?.remove();
_categoryFocusNode.dispose();
_categoryController.dispose();
_subCategoryOverlayEntry?.remove();
_subCategoryFocusNode.dispose();
_subCategoryController.dispose();
_subSubCategoryOverlayEntry?.remove();
_subSubCategoryFocusNode.dispose();
_subSubCategoryController.dispose();
// 추가 컨트롤러 정리
_nameController.dispose();
_serialNumberController.dispose();
_barcodeController.dispose();
_quantityController.dispose();
_warrantyCodeController.dispose();
_controller.dispose();
super.dispose();
}
// 포커스 변경 리스너 함수들
void _onPartnerFocusChange() {
if (!_partnerFocusNode.hasFocus) {
// 포커스가 벗어나면 드롭다운 닫기
_removePartnerDropdown();
} else {
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_partnerOverlayEntry);
}
}
void _onWarehouseFocusChange() {
if (!_warehouseFocusNode.hasFocus) {
// 포커스가 벗어나면 드롭다운 닫기
_removeWarehouseDropdown();
} else {
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_warehouseOverlayEntry);
}
}
void _onManufacturerFocusChange() {
if (!_manufacturerFocusNode.hasFocus) {
// 포커스가 벗어나면 드롭다운 닫기
_removeManufacturerDropdown();
} else {
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_manufacturerOverlayEntry);
}
}
void _onNameFieldFocusChange() {
if (!_nameFieldFocusNode.hasFocus) {
// 포커스가 벗어나면 드롭다운 닫기
_removeEquipmentNameDropdown();
} else {
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_equipmentNameOverlayEntry);
}
}
void _onCategoryFocusChange() {
if (!_categoryFocusNode.hasFocus) {
// 포커스가 벗어나면 드롭다운 닫기
_removeCategoryDropdown();
} else {
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_categoryOverlayEntry);
}
}
void _onSubCategoryFocusChange() {
if (!_subCategoryFocusNode.hasFocus) {
// 포커스가 벗어나면 드롭다운 닫기
_removeSubCategoryDropdown();
} else {
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_subCategoryOverlayEntry);
}
}
void _onSubSubCategoryFocusChange() {
if (!_subSubCategoryFocusNode.hasFocus) {
// 포커스가 벗어나면 드롭다운 닫기
_removeSubSubCategoryDropdown();
} else {
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_subSubCategoryOverlayEntry);
}
}
// 현재 포커스 필드 외의 다른 모든 드롭다운 제거
void _removeOtherDropdowns(OverlayEntry? currentOverlay) {
// 모든 드롭다운 중 현재 오버레이를 제외한 나머지 닫기
if (_partnerOverlayEntry != null &&
_partnerOverlayEntry != currentOverlay) {
_removePartnerDropdown();
}
if (_warehouseOverlayEntry != null &&
_warehouseOverlayEntry != currentOverlay) {
_removeWarehouseDropdown();
}
if (_manufacturerOverlayEntry != null &&
_manufacturerOverlayEntry != currentOverlay) {
_removeManufacturerDropdown();
}
if (_equipmentNameOverlayEntry != null &&
_equipmentNameOverlayEntry != currentOverlay) {
_removeEquipmentNameDropdown();
}
if (_categoryOverlayEntry != null &&
_categoryOverlayEntry != currentOverlay) {
_removeCategoryDropdown();
}
if (_subCategoryOverlayEntry != null &&
_subCategoryOverlayEntry != currentOverlay) {
_removeSubCategoryDropdown();
}
if (_subSubCategoryOverlayEntry != null &&
_subSubCategoryOverlayEntry != currentOverlay) {
_removeSubSubCategoryDropdown();
}
}
Future<void> _saveEquipmentIn() async {
// 로딩 다이얼로그 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => const Center(
child: CircularProgressIndicator(),
),
);
try {
final success = await _controller.save();
// 로딩 다이얼로그 닫기
if (!mounted) return;
Navigator.pop(context);
if (success) {
// 성공 메시지 표시
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.isEditMode ? '장비 정보가 수정되었습니다.' : '장비 입고가 등록되었습니다.'),
backgroundColor: Colors.green,
),
);
if (!mounted) return;
Navigator.pop(context, true);
} else {
// 에러 메시지 표시
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(_controller.error ?? '저장 중 오류가 발생했습니다.'),
backgroundColor: Colors.red,
),
);
}
} catch (e) {
// 로딩 다이얼로그 닫기
if (!mounted) return;
Navigator.pop(context);
// 예외 처리
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('오류: $e'),
backgroundColor: Colors.red,
),
);
}
}
void _showPartnerDropdown() {
// 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지
_removePartnerDropdown();
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_partnerOverlayEntry);
// 입력란의 정확한 RenderBox를 key로부터 참조
final RenderBox renderBox =
_partnerFieldKey.currentContext!.findRenderObject() as RenderBox;
final size = renderBox.size;
print('[구매처:showPartnerDropdown] 드롭다운 표시, width=${size.width}');
final itemsToShow = _controller.partnerCompanies;
print('[구매처:showPartnerDropdown] 드롭다운에 노출될 아이템: $itemsToShow');
_partnerOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _partnerLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...itemsToShow.map((item) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
print(
'[구매처:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})',
);
if (item.isEmpty) {
print('[구매처:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!');
}
setState(() {
// 프로그램적 변경 시작
_isProgrammaticPartnerChange = true;
print(
'[구매처:setState:드롭다운아이템] _controller.partnerCompany <- "$item"',
);
_controller.partnerCompany = item;
print(
'[구매처:setState:드롭다운아이템] _partnerController.text <- "$item"',
);
_partnerController.text = item;
});
print(
'[구매처:드롭다운아이템:클릭] setState 이후 _partnerController.text=${_partnerController.text}, _controller.partnerCompany=${_controller.partnerCompany}',
);
// 프로그램적 변경 종료 (다음 프레임에서)
WidgetsBinding.instance.addPostFrameCallback((_) {
_isProgrammaticPartnerChange = false;
});
_removePartnerDropdown();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(item),
),
);
}),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_partnerOverlayEntry!);
}
void _removePartnerDropdown() {
// 오버레이가 있으면 정상적으로 제거 및 null 처리
if (_partnerOverlayEntry != null) {
_partnerOverlayEntry!.remove();
_partnerOverlayEntry = null;
print('[구매처:removePartnerDropdown] 오버레이 제거 완료');
}
}
// 입고지 드롭다운 표시 함수
void _showWarehouseDropdown() {
// 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지
_removeWarehouseDropdown();
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_warehouseOverlayEntry);
// 입력란의 정확한 RenderBox를 key로부터 참조
final RenderBox renderBox =
_warehouseFieldKey.currentContext!.findRenderObject() as RenderBox;
final size = renderBox.size;
print('[입고지:showWarehouseDropdown] 드롭다운 표시, width=${size.width}');
final itemsToShow = _controller.warehouseLocations;
print('[입고지:showWarehouseDropdown] 드롭다운에 노출될 아이템: $itemsToShow');
_warehouseOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _warehouseLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...itemsToShow.map((item) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
print(
'[입고지:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})',
);
if (item.isEmpty) {
print('[입고지:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!');
}
setState(() {
// 프로그램적 변경 시작
_isProgrammaticWarehouseChange = true;
print(
'[입고지:setState:드롭다운아이템] _controller.warehouseLocation <- "$item"',
);
_controller.warehouseLocation = item;
print(
'[입고지:setState:드롭다운아이템] _warehouseController.text <- "$item"',
);
_warehouseController.text = item;
});
print(
'[입고지:드롭다운아이템:클릭] setState 이후 _warehouseController.text=${_warehouseController.text}, _controller.warehouseLocation=${_controller.warehouseLocation}',
);
// 프로그램적 변경 종료 (다음 프레임에서)
WidgetsBinding.instance.addPostFrameCallback((_) {
_isProgrammaticWarehouseChange = false;
});
_removeWarehouseDropdown();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(item),
),
);
}),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_warehouseOverlayEntry!);
}
void _removeWarehouseDropdown() {
// 오버레이가 있으면 정상적으로 제거 및 null 처리
if (_warehouseOverlayEntry != null) {
_warehouseOverlayEntry!.remove();
_warehouseOverlayEntry = null;
print('[입고지:removeWarehouseDropdown] 오버레이 제거 완료');
}
}
// 제조사 드롭다운 표시 함수
void _showManufacturerDropdown() {
// 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지
_removeManufacturerDropdown();
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_manufacturerOverlayEntry);
// 입력란의 정확한 RenderBox를 key로부터 참조
final RenderBox renderBox =
_manufacturerFieldKey.currentContext!.findRenderObject() as RenderBox;
final size = renderBox.size;
print('[제조사:showManufacturerDropdown] 드롭다운 표시, width=${size.width}');
final itemsToShow = _controller.manufacturers;
print('[제조사:showManufacturerDropdown] 드롭다운에 노출될 아이템: $itemsToShow');
_manufacturerOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _manufacturerLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...itemsToShow.map((item) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
print(
'[제조사:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})',
);
if (item.isEmpty) {
print('[제조사:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!');
}
setState(() {
// 프로그램적 변경 시작
_isProgrammaticManufacturerChange = true;
print(
'[제조사:setState:드롭다운아이템] _controller.manufacturer <- "$item"',
);
_controller.manufacturer = item;
print(
'[제조사:setState:드롭다운아이템] _manufacturerController.text <- "$item"',
);
_manufacturerController.text = item;
});
print(
'[제조사:드롭다운아이템:클릭] setState 이후 _manufacturerController.text=${_manufacturerController.text}, _controller.manufacturer=${_controller.manufacturer}',
);
// 프로그램적 변경 종료 (다음 프레임에서)
WidgetsBinding.instance.addPostFrameCallback((_) {
_isProgrammaticManufacturerChange = false;
});
_removeManufacturerDropdown();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(item),
),
);
}),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_manufacturerOverlayEntry!);
}
void _removeManufacturerDropdown() {
// 오버레이가 있으면 정상적으로 제거 및 null 처리
if (_manufacturerOverlayEntry != null) {
_manufacturerOverlayEntry!.remove();
_manufacturerOverlayEntry = null;
print('[제조사:removeManufacturerDropdown] 오버레이 제거 완료');
}
}
// 장비명 드롭다운 표시 함수
void _showEquipmentNameDropdown() {
// 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지
_removeEquipmentNameDropdown();
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_equipmentNameOverlayEntry);
// 입력란의 정확한 RenderBox를 key로부터 참조
final RenderBox renderBox =
_equipmentNameFieldKey.currentContext!.findRenderObject() as RenderBox;
final size = renderBox.size;
print('[장비명:showEquipmentNameDropdown] 드롭다운 표시, width=${size.width}');
final itemsToShow = _controller.equipmentNames;
print('[장비명:showEquipmentNameDropdown] 드롭다운에 노출될 아이템: $itemsToShow');
_equipmentNameOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _equipmentNameLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...itemsToShow.map((item) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
print(
'[장비명:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})',
);
if (item.isEmpty) {
print('[장비명:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!');
}
setState(() {
// 프로그램적 변경 시작
_isProgrammaticEquipmentNameChange = true;
print(
'[장비명:setState:드롭다운아이템] _controller.name <- "$item"',
);
_controller.name = item;
print(
'[장비명:setState:드롭다운아이템] _equipmentNameController.text <- "$item"',
);
_equipmentNameController.text = item;
});
print(
'[장비명:드롭다운아이템:클릭] setState 이후 _equipmentNameController.text=${_equipmentNameController.text}, _controller.name=${_controller.name}',
);
// 프로그램적 변경 종료 (다음 프레임에서)
WidgetsBinding.instance.addPostFrameCallback((_) {
_isProgrammaticEquipmentNameChange = false;
});
_removeEquipmentNameDropdown();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(item),
),
);
}),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_equipmentNameOverlayEntry!);
}
void _removeEquipmentNameDropdown() {
// 오버레이가 있으면 정상적으로 제거 및 null 처리
if (_equipmentNameOverlayEntry != null) {
_equipmentNameOverlayEntry!.remove();
_equipmentNameOverlayEntry = null;
print('[장비명:removeEquipmentNameDropdown] 오버레이 제거 완료');
}
}
// 대분류 드롭다운 표시 함수
void _showCategoryDropdown() {
// 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지
_removeCategoryDropdown();
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_categoryOverlayEntry);
// 입력란의 정확한 RenderBox를 key로부터 참조
final RenderBox renderBox =
_categoryFieldKey.currentContext!.findRenderObject() as RenderBox;
final size = renderBox.size;
print('[대분류:showCategoryDropdown] 드롭다운 표시, width=${size.width}');
final itemsToShow = _controller.categories;
print('[대분류:showCategoryDropdown] 드롭다운에 노출될 아이템: $itemsToShow');
_categoryOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _categoryLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...itemsToShow.map((item) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
print(
'[대분류:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})',
);
if (item.isEmpty) {
print('[대분류:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!');
}
setState(() {
// 프로그램적 변경 시작
_isProgrammaticCategoryChange = true;
print(
'[대분류:setState:드롭다운아이템] _controller.category <- "$item"',
);
_controller.category = item;
print(
'[대분류:setState:드롭다운아이템] _categoryController.text <- "$item"',
);
_categoryController.text = item;
});
print(
'[대분류:드롭다운아이템:클릭] setState 이후 _categoryController.text=${_categoryController.text}, _controller.category=${_controller.category}',
);
// 프로그램적 변경 종료 (다음 프레임에서)
WidgetsBinding.instance.addPostFrameCallback((_) {
_isProgrammaticCategoryChange = false;
});
_removeCategoryDropdown();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(item),
),
);
}),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_categoryOverlayEntry!);
}
void _removeCategoryDropdown() {
// 오버레이가 있으면 정상적으로 제거 및 null 처리
if (_categoryOverlayEntry != null) {
_categoryOverlayEntry!.remove();
_categoryOverlayEntry = null;
print('[대분류:removeCategoryDropdown] 오버레이 제거 완료');
}
}
// 중분류 드롭다운 표시 함수
void _showSubCategoryDropdown() {
// 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지
_removeSubCategoryDropdown();
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_subCategoryOverlayEntry);
// 입력란의 정확한 RenderBox를 key로부터 참조
final RenderBox renderBox =
_subCategoryFieldKey.currentContext!.findRenderObject() as RenderBox;
final size = renderBox.size;
print('[중분류:showSubCategoryDropdown] 드롭다운 표시, width=${size.width}');
final itemsToShow = _controller.subCategories;
print('[중분류:showSubCategoryDropdown] 드롭다운에 노출될 아이템: $itemsToShow');
_subCategoryOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _subCategoryLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...itemsToShow.map((item) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
print(
'[중분류:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})',
);
if (item.isEmpty) {
print('[중분류:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!');
}
setState(() {
// 프로그램적 변경 시작
_isProgrammaticSubCategoryChange = true;
print(
'[중분류:setState:드롭다운아이템] _controller.subCategory <- "$item"',
);
_controller.subCategory = item;
print(
'[중분류:setState:드롭다운아이템] _subCategoryController.text <- "$item"',
);
_subCategoryController.text = item;
});
print(
'[중분류:드롭다운아이템:클릭] setState 이후 _subCategoryController.text=${_subCategoryController.text}, _controller.subCategory=${_controller.subCategory}',
);
// 프로그램적 변경 종료 (다음 프레임에서)
WidgetsBinding.instance.addPostFrameCallback((_) {
_isProgrammaticSubCategoryChange = false;
});
_removeSubCategoryDropdown();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(item),
),
);
}),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_subCategoryOverlayEntry!);
}
void _removeSubCategoryDropdown() {
// 오버레이가 있으면 정상적으로 제거 및 null 처리
if (_subCategoryOverlayEntry != null) {
_subCategoryOverlayEntry!.remove();
_subCategoryOverlayEntry = null;
print('[중분류:removeSubCategoryDropdown] 오버레이 제거 완료');
}
}
// 소분류 드롭다운 표시 함수
void _showSubSubCategoryDropdown() {
// 항상 기존 오버레이를 먼저 제거하여 중복 생성 방지
_removeSubSubCategoryDropdown();
// 다른 모든 드롭다운 닫기
_removeOtherDropdowns(_subSubCategoryOverlayEntry);
// 입력란의 정확한 RenderBox를 key로부터 참조
final RenderBox renderBox =
_subSubCategoryFieldKey.currentContext!.findRenderObject() as RenderBox;
final size = renderBox.size;
print('[소분류:showSubSubCategoryDropdown] 드롭다운 표시, width=${size.width}');
final itemsToShow = _controller.subSubCategories;
print('[소분류:showSubSubCategoryDropdown] 드롭다운에 노출될 아이템: $itemsToShow');
_subSubCategoryOverlayEntry = OverlayEntry(
builder:
(context) => Positioned(
width: size.width,
child: CompositedTransformFollower(
link: _subSubCategoryLayerLink,
showWhenUnlinked: false,
offset: const Offset(0, 45),
child: Material(
elevation: 4,
borderRadius: BorderRadius.circular(4),
child: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(4),
boxShadow: [
BoxShadow(
color: Colors.grey.withValues(alpha: 0.3),
spreadRadius: 1,
blurRadius: 3,
offset: const Offset(0, 1),
),
],
),
constraints: const BoxConstraints(maxHeight: 200),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
...itemsToShow.map((item) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
print(
'[소분류:드롭다운아이템:클릭] 선택값: "$item" (길이: ${item.length})',
);
if (item.isEmpty) {
print('[소분류:드롭다운아이템:클릭] 경고: 빈 값이 선택됨!');
}
setState(() {
// 프로그램적 변경 시작
_isProgrammaticSubSubCategoryChange = true;
print(
'[소분류:setState:드롭다운아이템] _controller.subSubCategory <- "$item"',
);
_controller.subSubCategory = item;
print(
'[소분류:setState:드롭다운아이템] _subSubCategoryController.text <- "$item"',
);
_subSubCategoryController.text = item;
});
print(
'[소분류:드롭다운아이템:클릭] setState 이후 _subSubCategoryController.text=${_subSubCategoryController.text}, _controller.subSubCategory=${_controller.subSubCategory}',
);
// 프로그램적 변경 종료 (다음 프레임에서)
WidgetsBinding.instance.addPostFrameCallback((_) {
_isProgrammaticSubSubCategoryChange = false;
});
_removeSubSubCategoryDropdown();
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
width: double.infinity,
child: Text(item),
),
);
}),
],
),
),
),
),
),
),
);
Overlay.of(context).insert(_subSubCategoryOverlayEntry!);
}
void _removeSubSubCategoryDropdown() {
// 오버레이가 있으면 정상적으로 제거 및 null 처리
if (_subSubCategoryOverlayEntry != null) {
_subSubCategoryOverlayEntry!.remove();
_subSubCategoryOverlayEntry = null;
print('[소분류:removeSubSubCategoryDropdown] 오버레이 제거 완료');
}
}
@override
Widget build(BuildContext context) {
print(
'[구매처:build] _partnerController.text=${_partnerController.text}, _controller.partnerCompany=${_controller.partnerCompany}',
);
final inputText = _partnerController.text;
final suggestion = _getAutocompleteSuggestion(inputText);
final showSuggestion =
suggestion != null && suggestion.length > inputText.length;
print(
'[구매처:autocomplete] 입력값: "$inputText", 자동완성 후보: "$suggestion", showSuggestion=$showSuggestion',
);
return ChangeNotifierProvider<EquipmentInFormController>.value(
value: _controller,
child: Consumer<EquipmentInFormController>(
builder: (context, controller, child) {
// 수정 모드에서 로딩 중일 때 로딩 인디케이터 표시
if (controller.isEditMode && controller.isLoading) {
return Scaffold(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
body: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('장비 정보를 불러오는 중...'),
],
),
),
);
}
return GestureDetector(
// 화면의 다른 곳을 탭하면 모든 드롭다운 닫기
onTap: () {
// 현재 포커스된 위젯 포커스 해제
FocusScope.of(context).unfocus();
// 모든 드롭다운 닫기
_removePartnerDropdown();
_removeWarehouseDropdown();
_removeManufacturerDropdown();
_removeEquipmentNameDropdown();
_removeCategoryDropdown();
_removeSubCategoryDropdown();
_removeSubSubCategoryDropdown();
},
child: FormLayoutTemplate(
title: _controller.isEditMode ? '장비 입고 수정' : '장비 입고 등록',
onSave: _controller.isLoading ? null : _saveEquipmentIn,
onCancel: () => Navigator.of(context).pop(),
saveButtonText: _controller.isEditMode ? '수정 완료' : '입고 등록',
isLoading: _controller.isSaving,
child: Form(
key: _controller.formKey,
child: SingleChildScrollView(
padding: const EdgeInsets.all(UIConstants.formPadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 기본 정보 섹션
FormSection(
title: '기본 정보',
subtitle: '입고할 장비의 기본 정보를 입력하세요',
children: [
// 장비 유형 선택 (라디오 버튼)
FormFieldWrapper(
label: '장비 유형',
required: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: RadioListTile<String>(
title: const Text(
'신제품',
style: TextStyle(fontSize: 14),
),
value: EquipmentType.new_,
groupValue: _controller.equipmentType,
onChanged: (value) {
setState(() {
_controller.equipmentType = value!;
});
},
contentPadding: EdgeInsets.zero,
dense: true,
),
),
Expanded(
child: RadioListTile<String>(
title: const Text(
'중고',
style: TextStyle(fontSize: 14),
),
value: EquipmentType.used,
groupValue: _controller.equipmentType,
onChanged: (value) {
setState(() {
_controller.equipmentType = value!;
});
},
contentPadding: EdgeInsets.zero,
dense: true,
),
),
Expanded(
child: RadioListTile<String>(
title: const Text(
'계약',
style: TextStyle(fontSize: 14),
),
subtitle: const Text(
'(입고후 즉각 출고)',
style: TextStyle(fontSize: 11),
),
value: EquipmentType.contract,
groupValue: _controller.equipmentType,
onChanged: (value) {
setState(() {
_controller.equipmentType = value!;
});
},
contentPadding: EdgeInsets.zero,
dense: true,
),
),
],
),
],
),
),
// 1행: 구매처(파트너사), 입고지
Row(
children: [
Expanded(
child: FormFieldWrapper(
label: '구매처',
required: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입력란(CompositedTransformTarget으로 감싸기)
CompositedTransformTarget(
link: _partnerLayerLink,
child: TextFormField(
key: _partnerFieldKey,
controller: _partnerController,
focusNode: _partnerFocusNode,
decoration: InputDecoration(
labelText: '구매처',
hintText: '구매처를 입력 또는 선택하세요',
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: _showPartnerDropdown,
),
),
onChanged: (value) {
print('[구매처:onChanged] 입력값: "$value"');
// 프로그램적 변경이면 무시
if (_isProgrammaticPartnerChange) {
print('[구매처:onChanged] 프로그램적 변경이므로 무시');
return;
}
setState(() {
print(
'[구매처:setState:onChanged] _controller.partnerCompany <- "$value"',
);
_controller.partnerCompany = value;
});
},
onFieldSubmitted: (value) {
// 엔터 입력 시 자동완성
print(
'[구매처:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$suggestion", showSuggestion=$showSuggestion',
);
if (showSuggestion) {
setState(() {
print(
'[구매처:onFieldSubmitted] 자동완성 적용: "$suggestion"',
);
_isProgrammaticPartnerChange = true;
_partnerController.text = suggestion;
_controller.partnerCompany = suggestion;
// 커서를 맨 뒤로 이동
_partnerController
.selection = TextSelection.collapsed(
offset: suggestion.length,
);
print(
'[구매처:onFieldSubmitted] 커서 위치: ${_partnerController.selection.start}',
);
});
WidgetsBinding.instance
.addPostFrameCallback((_) {
_isProgrammaticPartnerChange = false;
});
}
},
),
),
// 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시
if (showSuggestion)
Padding(
padding: const EdgeInsets.only(
left: 12,
top: 2,
),
child: Text(
suggestion,
style: const TextStyle(
color: Color(0xFF1976D2),
fontWeight: FontWeight.bold,
fontSize: 13, // 더 작은 글씨
),
),
),
],
),
),
),
const SizedBox(width: 16),
Expanded(
child: FormFieldWrapper(
label: '입고지',
required: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입력란(CompositedTransformTarget으로 감싸기)
CompositedTransformTarget(
link: _warehouseLayerLink,
child: TextFormField(
key: _warehouseFieldKey,
controller: _warehouseController,
focusNode: _warehouseFocusNode,
decoration: InputDecoration(
labelText: '입고지',
hintText: '입고지를 입력 또는 선택하세요',
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: _showWarehouseDropdown,
),
),
onChanged: (value) {
print('[입고지:onChanged] 입력값: "$value"');
// 프로그램적 변경이면 무시
if (_isProgrammaticWarehouseChange) {
print('[입고지:onChanged] 프로그램적 변경이므로 무시');
return;
}
setState(() {
print(
'[입고지:setState:onChanged] _controller.warehouseLocation <- "$value"',
);
_controller.warehouseLocation = value;
});
},
onFieldSubmitted: (value) {
// 엔터 입력 시 자동완성
final warehouseSuggestion =
_getWarehouseAutocompleteSuggestion(
value,
);
final showWarehouseSuggestion =
warehouseSuggestion != null &&
warehouseSuggestion.length > value.length;
print(
'[입고지:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$warehouseSuggestion", showWarehouseSuggestion=$showWarehouseSuggestion',
);
if (showWarehouseSuggestion) {
setState(() {
print(
'[입고지:onFieldSubmitted] 자동완성 적용: "$warehouseSuggestion"',
);
_isProgrammaticWarehouseChange = true;
_warehouseController.text =
warehouseSuggestion;
_controller.warehouseLocation =
warehouseSuggestion;
// 커서를 맨 뒤로 이동
_warehouseController
.selection = TextSelection.collapsed(
offset: warehouseSuggestion.length,
);
print(
'[입고지:onFieldSubmitted] 커서 위치: ${_warehouseController.selection.start}',
);
});
WidgetsBinding.instance
.addPostFrameCallback((_) {
_isProgrammaticWarehouseChange =
false;
});
}
},
),
),
// 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시
if (_getWarehouseAutocompleteSuggestion(
_warehouseController.text,
) !=
null &&
_getWarehouseAutocompleteSuggestion(
_warehouseController.text,
)!.length >
_warehouseController.text.length)
Padding(
padding: const EdgeInsets.only(
left: 12,
top: 2,
),
child: Text(
_getWarehouseAutocompleteSuggestion(
_warehouseController.text,
)!,
style: const TextStyle(
color: Color(0xFF1976D2),
fontWeight: FontWeight.bold,
fontSize: 13, // 더 작은 글씨
),
),
),
],
),
),
),
],
),
// 2행: 제조사, 장비명
Row(
children: [
Expanded(
child: FormFieldWrapper(
label: '제조사',
required: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입력란(CompositedTransformTarget으로 감싸기)
CompositedTransformTarget(
link: _manufacturerLayerLink,
child: TextFormField(
key: _manufacturerFieldKey,
controller: _manufacturerController,
focusNode: _manufacturerFocusNode,
decoration: InputDecoration(
labelText: '제조사',
hintText: '제조사를 입력 또는 선택하세요',
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: _showManufacturerDropdown,
),
),
onChanged: (value) {
print('[제조사:onChanged] 입력값: "$value"');
// 프로그램적 변경이면 무시
if (_isProgrammaticManufacturerChange) {
print('[제조사:onChanged] 프로그램적 변경이므로 무시');
return;
}
setState(() {
print(
'[제조사:setState:onChanged] _controller.manufacturer <- "$value"',
);
_controller.manufacturer = value;
});
},
onFieldSubmitted: (value) {
// 엔터 입력 시 자동완성
final manufacturerSuggestion =
_getManufacturerAutocompleteSuggestion(
value,
);
final showManufacturerSuggestion =
manufacturerSuggestion != null &&
manufacturerSuggestion.length >
value.length;
print(
'[제조사:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$manufacturerSuggestion", showManufacturerSuggestion=$showManufacturerSuggestion',
);
if (showManufacturerSuggestion) {
setState(() {
print(
'[제조사:onFieldSubmitted] 자동완성 적용: "$manufacturerSuggestion"',
);
_isProgrammaticManufacturerChange = true;
_manufacturerController.text =
manufacturerSuggestion;
_controller.manufacturer =
manufacturerSuggestion;
// 커서를 맨 뒤로 이동
_manufacturerController
.selection = TextSelection.collapsed(
offset: manufacturerSuggestion.length,
);
print(
'[제조사:onFieldSubmitted] 커서 위치: ${_manufacturerController.selection.start}',
);
});
WidgetsBinding.instance
.addPostFrameCallback((_) {
_isProgrammaticManufacturerChange =
false;
});
}
},
),
),
// 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시
if (_getManufacturerAutocompleteSuggestion(
_manufacturerController.text,
) !=
null &&
_getManufacturerAutocompleteSuggestion(
_manufacturerController.text,
)!.length >
_manufacturerController.text.length)
Padding(
padding: const EdgeInsets.only(
left: 12,
top: 2,
),
child: Text(
_getManufacturerAutocompleteSuggestion(
_manufacturerController.text,
)!,
style: const TextStyle(
color: Color(0xFF1976D2),
fontWeight: FontWeight.bold,
fontSize: 13, // 더 작은 글씨
),
),
),
],
),
),
),
const SizedBox(width: 16),
Expanded(
child: FormFieldWrapper(
label: '장비명',
required: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입력란(CompositedTransformTarget으로 감싸기)
CompositedTransformTarget(
link: _equipmentNameLayerLink,
child: TextFormField(
key: _equipmentNameFieldKey,
controller: _equipmentNameController,
focusNode: _nameFieldFocusNode,
decoration: InputDecoration(
labelText: '장비명',
hintText: '장비명을 입력 또는 선택하세요',
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: _showEquipmentNameDropdown,
),
),
onChanged: (value) {
print('[장비명:onChanged] 입력값: "$value"');
// 프로그램적 변경이면 무시
if (_isProgrammaticEquipmentNameChange) {
print('[장비명:onChanged] 프로그램적 변경이므로 무시');
return;
}
setState(() {
print(
'[장비명:setState:onChanged] _controller.name <- "$value"',
);
_controller.name = value;
});
},
onFieldSubmitted: (value) {
// 엔터 입력 시 자동완성
final equipmentNameSuggestion =
_getEquipmentNameAutocompleteSuggestion(
value,
);
final showEquipmentNameSuggestion =
equipmentNameSuggestion != null &&
equipmentNameSuggestion.length >
value.length;
print(
'[장비명:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$equipmentNameSuggestion", showEquipmentNameSuggestion=$showEquipmentNameSuggestion',
);
if (showEquipmentNameSuggestion) {
setState(() {
print(
'[장비명:onFieldSubmitted] 자동완성 적용: "$equipmentNameSuggestion"',
);
_isProgrammaticEquipmentNameChange = true;
_equipmentNameController.text =
equipmentNameSuggestion;
_controller.name =
equipmentNameSuggestion;
// 커서를 맨 뒤로 이동
_equipmentNameController
.selection = TextSelection.collapsed(
offset: equipmentNameSuggestion.length,
);
print(
'[장비명:onFieldSubmitted] 커서 위치: ${_equipmentNameController.selection.start}',
);
});
WidgetsBinding.instance
.addPostFrameCallback((_) {
_isProgrammaticEquipmentNameChange =
false;
});
}
},
),
),
// 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시
if (_getEquipmentNameAutocompleteSuggestion(
_equipmentNameController.text,
) !=
null &&
_getEquipmentNameAutocompleteSuggestion(
_equipmentNameController.text,
)!.length >
_equipmentNameController.text.length)
Padding(
padding: const EdgeInsets.only(
left: 12,
top: 2,
),
child: Text(
_getEquipmentNameAutocompleteSuggestion(
_equipmentNameController.text,
)!,
style: const TextStyle(
color: Color(0xFF1976D2),
fontWeight: FontWeight.bold,
fontSize: 13, // 더 작은 글씨
),
),
),
],
),
),
),
],
),
// 3행: 대분류, 중분류, 소분류
Row(
children: [
Expanded(
child: FormFieldWrapper(
label: '대분류',
required: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입력란(CompositedTransformTarget으로 감싸기)
CompositedTransformTarget(
link: _categoryLayerLink,
child: TextFormField(
key: _categoryFieldKey,
controller: _categoryController,
focusNode: _categoryFocusNode,
decoration: InputDecoration(
labelText: '대분류',
hintText: '대분류를 입력 또는 선택하세요',
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: _showCategoryDropdown,
),
),
onChanged: (value) {
print('[대분류:onChanged] 입력값: "$value"');
// 프로그램적 변경이면 무시
if (_isProgrammaticCategoryChange) {
print('[대분류:onChanged] 프로그램적 변경이므로 무시');
return;
}
setState(() {
print(
'[대분류:setState:onChanged] _controller.category <- "$value"',
);
_controller.category = value;
});
},
onFieldSubmitted: (value) {
// 엔터 입력 시 자동완성
final categorySuggestion =
_getCategoryAutocompleteSuggestion(value);
final showCategorySuggestion =
categorySuggestion != null &&
categorySuggestion.length > value.length;
print(
'[대분류:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$categorySuggestion", showCategorySuggestion=$showCategorySuggestion',
);
if (showCategorySuggestion) {
setState(() {
print(
'[대분류:onFieldSubmitted] 자동완성 적용: "$categorySuggestion"',
);
_isProgrammaticCategoryChange = true;
_categoryController.text =
categorySuggestion;
_controller.category = categorySuggestion;
// 커서를 맨 뒤로 이동
_categoryController
.selection = TextSelection.collapsed(
offset: categorySuggestion.length,
);
print(
'[대분류:onFieldSubmitted] 커서 위치: ${_categoryController.selection.start}',
);
});
WidgetsBinding.instance
.addPostFrameCallback((_) {
_isProgrammaticCategoryChange = false;
});
}
},
),
),
// 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시
if (_getCategoryAutocompleteSuggestion(
_categoryController.text,
) !=
null &&
_getCategoryAutocompleteSuggestion(
_categoryController.text,
)!.length >
_categoryController.text.length)
Padding(
padding: const EdgeInsets.only(
left: 12,
top: 2,
),
child: Text(
_getCategoryAutocompleteSuggestion(
_categoryController.text,
)!,
style: const TextStyle(
color: Color(0xFF1976D2),
fontWeight: FontWeight.bold,
fontSize: 13, // 더 작은 글씨
),
),
),
],
),
),
),
const SizedBox(width: 16),
Expanded(
child: FormFieldWrapper(
label: '중분류',
required: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입력란(CompositedTransformTarget으로 감싸기)
CompositedTransformTarget(
link: _subCategoryLayerLink,
child: TextFormField(
key: _subCategoryFieldKey,
controller: _subCategoryController,
focusNode: _subCategoryFocusNode,
decoration: InputDecoration(
labelText: '중분류',
hintText: '중분류를 입력 또는 선택하세요',
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: _showSubCategoryDropdown,
),
),
onChanged: (value) {
print('[중분류:onChanged] 입력값: "$value"');
// 프로그램적 변경이면 무시
if (_isProgrammaticSubCategoryChange) {
print('[중분류:onChanged] 프로그램적 변경이므로 무시');
return;
}
setState(() {
print(
'[중분류:setState:onChanged] _controller.subCategory <- "$value"',
);
_controller.subCategory = value;
});
},
onFieldSubmitted: (value) {
// 엔터 입력 시 자동완성
final subCategorySuggestion =
_getSubCategoryAutocompleteSuggestion(
value,
);
final showSubCategorySuggestion =
subCategorySuggestion != null &&
subCategorySuggestion.length >
value.length;
print(
'[중분류:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$subCategorySuggestion", showSubCategorySuggestion=$showSubCategorySuggestion',
);
if (showSubCategorySuggestion) {
setState(() {
print(
'[중분류:onFieldSubmitted] 자동완성 적용: "$subCategorySuggestion"',
);
_isProgrammaticSubCategoryChange = true;
_subCategoryController.text =
subCategorySuggestion;
_controller.subCategory =
subCategorySuggestion;
// 커서를 맨 뒤로 이동
_subCategoryController
.selection = TextSelection.collapsed(
offset: subCategorySuggestion.length,
);
print(
'[중분류:onFieldSubmitted] 커서 위치: ${_subCategoryController.selection.start}',
);
});
WidgetsBinding.instance
.addPostFrameCallback((_) {
_isProgrammaticSubCategoryChange =
false;
});
}
},
),
),
// 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시
if (_getSubCategoryAutocompleteSuggestion(
_subCategoryController.text,
) !=
null &&
_getSubCategoryAutocompleteSuggestion(
_subCategoryController.text,
)!.length >
_subCategoryController.text.length)
Padding(
padding: const EdgeInsets.only(
left: 12,
top: 2,
),
child: Text(
_getSubCategoryAutocompleteSuggestion(
_subCategoryController.text,
)!,
style: const TextStyle(
color: Color(0xFF1976D2),
fontWeight: FontWeight.bold,
fontSize: 13, // 더 작은 글씨
),
),
),
],
),
),
),
const SizedBox(width: 16),
Expanded(
child: FormFieldWrapper(
label: '소분류',
required: true,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 입력란(CompositedTransformTarget으로 감싸기)
CompositedTransformTarget(
link: _subSubCategoryLayerLink,
child: TextFormField(
key: _subSubCategoryFieldKey,
controller: _subSubCategoryController,
focusNode: _subSubCategoryFocusNode,
decoration: InputDecoration(
labelText: '소분류',
hintText: '소분류를 입력 또는 선택하세요',
suffixIcon: IconButton(
icon: const Icon(Icons.arrow_drop_down),
onPressed: _showSubSubCategoryDropdown,
),
),
onChanged: (value) {
print('[소분류:onChanged] 입력값: "$value"');
// 프로그램적 변경이면 무시
if (_isProgrammaticSubSubCategoryChange) {
print('[소분류:onChanged] 프로그램적 변경이므로 무시');
return;
}
setState(() {
print(
'[소분류:setState:onChanged] _controller.subSubCategory <- "$value"',
);
_controller.subSubCategory = value;
});
},
onFieldSubmitted: (value) {
// 엔터 입력 시 자동완성
final subSubCategorySuggestion =
_getSubSubCategoryAutocompleteSuggestion(
value,
);
final showSubSubCategorySuggestion =
subSubCategorySuggestion != null &&
subSubCategorySuggestion.length >
value.length;
print(
'[소분류:onFieldSubmitted] 엔터 입력됨, 입력값: "$value", 자동완성 후보: "$subSubCategorySuggestion", showSubSubCategorySuggestion=$showSubSubCategorySuggestion',
);
if (showSubSubCategorySuggestion) {
setState(() {
print(
'[소분류:onFieldSubmitted] 자동완성 적용: "$subSubCategorySuggestion"',
);
_isProgrammaticSubSubCategoryChange =
true;
_subSubCategoryController.text =
subSubCategorySuggestion;
_controller.subSubCategory =
subSubCategorySuggestion;
// 커서를 맨 뒤로 이동
_subSubCategoryController
.selection = TextSelection.collapsed(
offset: subSubCategorySuggestion.length,
);
print(
'[소분류:onFieldSubmitted] 커서 위치: ${_subSubCategoryController.selection.start}',
);
});
WidgetsBinding.instance
.addPostFrameCallback((_) {
_isProgrammaticSubSubCategoryChange =
false;
});
}
},
),
),
// 입력란 아래에 자동완성 후보 전체를 더 작은 글씨로 명확하게 표시
if (_getSubSubCategoryAutocompleteSuggestion(
_subSubCategoryController.text,
) !=
null &&
_getSubSubCategoryAutocompleteSuggestion(
_subSubCategoryController.text,
)!.length >
_subSubCategoryController.text.length)
Padding(
padding: const EdgeInsets.only(
left: 12,
top: 2,
),
child: Text(
_getSubSubCategoryAutocompleteSuggestion(
_subSubCategoryController.text,
)!,
style: const TextStyle(
color: Color(0xFF1976D2),
fontWeight: FontWeight.bold,
fontSize: 13, // 더 작은 글씨
),
),
),
],
),
),
),
],
),
// 시리얼 번호 유무 토글
FormFieldWrapper(
label: '시리얼 번호',
required: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Checkbox(
value: _controller.hasSerialNumber,
onChanged: (value) {
setState(() {
_controller.hasSerialNumber = value ?? true;
});
},
),
const Text('시리얼 번호 있음'),
],
),
if (_controller.hasSerialNumber)
TextFormField(
initialValue: _controller.serialNumber,
decoration: const InputDecoration(
hintText: '시리얼 번호를 입력하세요',
),
validator: (value) {
if (_controller.hasSerialNumber &&
(value == null || value.isEmpty)) {
return '시리얼 번호를 입력해주세요';
}
return null;
},
onSaved: (value) {
_controller.serialNumber = value ?? '';
},
),
],
),
),
// 바코드 필드
FormFieldWrapper(
label: '바코드',
required: false,
child: TextFormField(
initialValue: _controller.barcode,
decoration: const InputDecoration(
hintText: '바코드를 입력하세요 (선택사항)',
),
onSaved: (value) {
_controller.barcode = value ?? '';
},
),
),
// 수량 필드
FormFieldWrapper(
label: '수량',
required: true,
child: TextFormField(
initialValue: _controller.quantity.toString(),
decoration: const InputDecoration(hintText: '수량을 입력하세요'),
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
validator: (value) {
if (value == null || value.isEmpty) {
return '수량을 입력해주세요';
}
if (int.tryParse(value) == null ||
int.parse(value) <= 0) {
return '유효한 수량을 입력해주세요';
}
return null;
},
onSaved: (value) {
_controller.quantity = int.tryParse(value ?? '1') ?? 1;
},
),
),
// 입고일 필드
FormFieldWrapper(
label: '입고일',
required: true,
child: InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _controller.inDate,
firstDate: DateTime(2000),
lastDate: DateTime.now(),
);
if (picked != null && picked != _controller.inDate) {
setState(() {
_controller.inDate = picked;
// 입고일 변경 시 워런티 시작일도 같이 변경
_controller.warrantyStartDate = picked;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 15,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'${_controller.inDate.year}-${_controller.inDate.month.toString().padLeft(2, '0')}-${_controller.inDate.day.toString().padLeft(2, '0')}',
style: ShadcnTheme.bodyMedium,
),
const Icon(Icons.calendar_today, size: 20),
],
),
),
),
),
// 워런티 정보 섹션
const SizedBox(height: 16),
Text('워런티 정보', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 12),
// 워런티 필드들을 1행으로 통합 (전체 너비 사용)
SizedBox(
width: double.infinity,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 워런티 라이센스
Expanded(
flex: 2,
child: FormFieldWrapper(
label: '워런티 라이센스',
required: false,
child: TextFormField(
initialValue: _controller.warrantyLicense ?? '',
decoration: const InputDecoration(
hintText: '워런티 라이센스명을 입력하세요',
),
onChanged: (value) {
_controller.warrantyLicense = value;
},
),
),
),
const SizedBox(width: 12),
// 워런티 코드 입력란 추가
Expanded(
flex: 2,
child: FormFieldWrapper(
label: '워런티 코드',
required: false,
child: TextFormField(
initialValue: _controller.warrantyCode ?? '',
decoration: const InputDecoration(
hintText: '워런티 코드를 입력하세요',
),
onChanged: (value) {
_controller.warrantyCode = value;
},
),
),
),
const SizedBox(width: 12),
// 워런티 시작일
Expanded(
flex: 1,
child: FormFieldWrapper(
label: '시작일',
required: false,
child: InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _controller.warrantyStartDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null &&
picked != _controller.warrantyStartDate) {
setState(() {
_controller.warrantyStartDate = picked;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 15,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'${_controller.warrantyStartDate.year}-${_controller.warrantyStartDate.month.toString().padLeft(2, '0')}-${_controller.warrantyStartDate.day.toString().padLeft(2, '0')}',
style: ShadcnTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.calendar_today, size: 16),
],
),
),
),
),
),
const SizedBox(width: 12),
// 워런티 종료일
Expanded(
flex: 1,
child: FormFieldWrapper(
label: '종료일',
required: false,
child: InkWell(
onTap: () async {
final DateTime? picked = await showDatePicker(
context: context,
initialDate: _controller.warrantyEndDate,
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null &&
picked != _controller.warrantyEndDate) {
setState(() {
_controller.warrantyEndDate = picked;
});
}
},
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 15,
),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
'${_controller.warrantyEndDate.year}-${_controller.warrantyEndDate.month.toString().padLeft(2, '0')}-${_controller.warrantyEndDate.day.toString().padLeft(2, '0')}',
style: ShadcnTheme.bodyMedium,
overflow: TextOverflow.ellipsis,
),
),
const Icon(Icons.calendar_today, size: 16),
],
),
),
),
),
),
const SizedBox(width: 12),
// 워런티 기간 요약
Expanded(
flex: 1,
child: FormFieldWrapper(
label: '워런티 기간',
required: false,
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 15,
),
decoration: BoxDecoration(
color: Colors.grey.shade100,
border: Border.all(color: Colors.grey.shade400),
borderRadius: BorderRadius.circular(4),
),
alignment: Alignment.centerLeft,
child: Text(
' ${_controller.getWarrantyPeriodSummary()}',
style: TextStyle(
color: Colors.grey.shade700,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
),
),
),
),
],
),
),
// 비고 입력란 추가
const SizedBox(height: 16),
FormFieldWrapper(
label: '비고',
required: false,
child: RemarkInput(
controller: _controller.remarkController,
hint: '비고를 입력하세요',
minLines: 4,
),
),
], // FormSection children 끝
), // FormSection 끝
], // Column children 끝
), // SingleChildScrollView child 끝
), // Form child 끝
), // FormLayoutTemplate child 끝
), // GestureDetector 끝
);
}, // Consumer builder 끝
), // Consumer 끝
); // ChangeNotifierProvider.value 끝
}
}