프로젝트 최초 커밋
This commit is contained in:
278
lib/screens/common/widgets/address_input.dart
Normal file
278
lib/screens/common/widgets/address_input.dart
Normal file
@@ -0,0 +1,278 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/custom_widgets.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
import 'package:superport/utils/address_constants.dart';
|
||||
import 'package:superport/models/address_model.dart';
|
||||
|
||||
/// 주소 입력 컴포넌트
|
||||
///
|
||||
/// 우편번호, 시/도 드롭다운, 상세주소로 구성된 주소 입력 폼입니다.
|
||||
/// 1행 3열 구조로 배치되어 있으며, 각 필드는 SRP 원칙에 따라 개별적으로 관리됩니다.
|
||||
class AddressInput extends StatefulWidget {
|
||||
/// 최초 우편번호 값
|
||||
final String initialZipCode;
|
||||
|
||||
/// 최초 시/도 값
|
||||
final String initialRegion;
|
||||
|
||||
/// 최초 상세 주소 값
|
||||
final String initialDetailAddress;
|
||||
|
||||
/// 주소가 변경될 때 호출되는 콜백 함수
|
||||
/// zipCode, region, detailAddress를 매개변수로 전달합니다.
|
||||
final Function(String zipCode, String region, String detailAddress)
|
||||
onAddressChanged;
|
||||
|
||||
/// 필수 입력 여부
|
||||
final bool isRequired;
|
||||
|
||||
const AddressInput({
|
||||
Key? key,
|
||||
this.initialZipCode = '',
|
||||
this.initialRegion = '',
|
||||
this.initialDetailAddress = '',
|
||||
required this.onAddressChanged,
|
||||
this.isRequired = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AddressInput> createState() => _AddressInputState();
|
||||
|
||||
/// Address 객체를 받아 읽기 전용으로 표시하는 위젯
|
||||
static Widget readonly({required Address address}) {
|
||||
// 회사 리스트와 동일하게 address.toString() 사용, 스타일도 bodyStyle로 통일
|
||||
return Text(address.toString(), style: AppThemeTailwind.bodyStyle);
|
||||
}
|
||||
}
|
||||
|
||||
class _AddressInputState extends State<AddressInput> {
|
||||
// 텍스트 컨트롤러
|
||||
late TextEditingController _zipCodeController;
|
||||
late TextEditingController _detailAddressController;
|
||||
|
||||
// 드롭다운 관련 변수
|
||||
String _selectedRegion = '';
|
||||
bool _showRegionDropdown = false;
|
||||
|
||||
// 레이어 링크 (드롭다운 위치 조정용)
|
||||
final LayerLink _regionLayerLink = LayerLink();
|
||||
|
||||
// 오버레이 엔트리 (드롭다운 메뉴)
|
||||
OverlayEntry? _regionOverlayEntry;
|
||||
|
||||
// 포커스 노드
|
||||
final FocusNode _regionFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_zipCodeController = TextEditingController(text: widget.initialZipCode);
|
||||
_selectedRegion = widget.initialRegion;
|
||||
_detailAddressController = TextEditingController(
|
||||
text: widget.initialDetailAddress,
|
||||
);
|
||||
|
||||
// 컨트롤러 변경 리스너 등록
|
||||
_zipCodeController.addListener(_notifyAddressChanged);
|
||||
_detailAddressController.addListener(_notifyAddressChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_zipCodeController.dispose();
|
||||
_detailAddressController.dispose();
|
||||
_removeRegionOverlay();
|
||||
_regionFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 주소 변경을 상위 위젯에 알립니다.
|
||||
void _notifyAddressChanged() {
|
||||
widget.onAddressChanged(
|
||||
_zipCodeController.text,
|
||||
_selectedRegion,
|
||||
_detailAddressController.text,
|
||||
);
|
||||
}
|
||||
|
||||
/// 시/도 드롭다운을 토글합니다.
|
||||
void _toggleRegionDropdown() {
|
||||
setState(() {
|
||||
if (_showRegionDropdown) {
|
||||
_removeRegionOverlay();
|
||||
} else {
|
||||
_showRegionDropdown = true;
|
||||
_showRegionOverlay();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// 시/도 드롭다운 오버레이를 제거합니다.
|
||||
void _removeRegionOverlay() {
|
||||
_regionOverlayEntry?.remove();
|
||||
_regionOverlayEntry = null;
|
||||
_showRegionDropdown = false;
|
||||
}
|
||||
|
||||
/// 시/도 드롭다운 오버레이를 표시합니다.
|
||||
void _showRegionOverlay() {
|
||||
final RenderBox renderBox = context.findRenderObject() as RenderBox;
|
||||
final size = renderBox.size;
|
||||
final offset = renderBox.localToGlobal(Offset.zero);
|
||||
|
||||
final availableHeight =
|
||||
MediaQuery.of(context).size.height - offset.dy - 100;
|
||||
final maxHeight = 300.0 < availableHeight ? 300.0 : availableHeight;
|
||||
|
||||
_regionOverlayEntry = OverlayEntry(
|
||||
builder:
|
||||
(context) => Positioned(
|
||||
width: 200,
|
||||
child: CompositedTransformFollower(
|
||||
link: _regionLayerLink,
|
||||
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.withOpacity(0.3),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
constraints: BoxConstraints(maxHeight: maxHeight),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...KoreanRegions.topLevel.map(
|
||||
(region) => InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedRegion = region;
|
||||
_removeRegionOverlay();
|
||||
_notifyAddressChanged();
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: Text(
|
||||
region,
|
||||
style: AppThemeTailwind.bodyStyle.copyWith(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Overlay.of(context).insert(_regionOverlayEntry!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormFieldWrapper(
|
||||
label: '주소',
|
||||
isRequired: widget.isRequired,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 우편번호 입력 필드 (1열)
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _zipCodeController,
|
||||
decoration: InputDecoration(
|
||||
hintText: AddressLabels.zipCodeHint,
|
||||
labelText: AddressLabels.zipCode,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 시/도 선택 드롭다운 (2열)
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: CompositedTransformTarget(
|
||||
link: _regionLayerLink,
|
||||
child: InkWell(
|
||||
onTap: _toggleRegionDropdown,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 0,
|
||||
),
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade400),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
_selectedRegion.isEmpty
|
||||
? AddressLabels.regionHint
|
||||
: _selectedRegion,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color:
|
||||
_selectedRegion.isEmpty
|
||||
? Colors.grey.shade600
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
const Icon(Icons.arrow_drop_down),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// 상세 주소 입력 필드 (3열)
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: TextField(
|
||||
controller: _detailAddressController,
|
||||
decoration: InputDecoration(
|
||||
hintText: AddressLabels.detailHint,
|
||||
labelText: AddressLabels.detail,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
255
lib/screens/common/widgets/autocomplete_dropdown_field.dart
Normal file
255
lib/screens/common/widgets/autocomplete_dropdown_field.dart
Normal file
@@ -0,0 +1,255 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart'; // kDebugMode 사용
|
||||
|
||||
/// 드롭다운 + 자동완성 + 텍스트 입력을 모두 지원하는 공통 위젯
|
||||
///
|
||||
/// - 텍스트 입력 시 자동완성 추천 리스트 노출
|
||||
/// - 드롭다운 버튼 클릭 시 전체 리스트 노출
|
||||
/// - 직접 입력, 선택 모두 가능
|
||||
/// - 재사용성 및 SRP 준수
|
||||
class AutocompleteDropdownField extends StatefulWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final List<String> items;
|
||||
final bool isRequired;
|
||||
final String hintText;
|
||||
final void Function(String) onChanged;
|
||||
final void Function(String) onSelected;
|
||||
final bool enabled;
|
||||
|
||||
const AutocompleteDropdownField({
|
||||
Key? key,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onChanged,
|
||||
required this.onSelected,
|
||||
this.isRequired = false,
|
||||
this.hintText = '',
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<AutocompleteDropdownField> createState() =>
|
||||
_AutocompleteDropdownFieldState();
|
||||
}
|
||||
|
||||
class _AutocompleteDropdownFieldState extends State<AutocompleteDropdownField> {
|
||||
late TextEditingController _controller;
|
||||
late final FocusNode _focusNode;
|
||||
late List<String> _filteredItems;
|
||||
bool _showDropdown = false;
|
||||
// 위젯 고유 키 추가 (동적 값 기반 키 대신 고정된 ValueKey 사용)
|
||||
final GlobalKey _fieldKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.value);
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'[AutocompleteDropdownField:initState] label=${widget.label}, value=${widget.value}',
|
||||
);
|
||||
}
|
||||
_focusNode = FocusNode();
|
||||
_filteredItems = List.from(widget.items);
|
||||
_controller.addListener(_onTextChanged);
|
||||
_focusNode.addListener(_handleFocusChange);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant AutocompleteDropdownField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// 항상 부모의 value와 내부 컨트롤러를 동기화 (동기화 누락 방지)
|
||||
_controller.text = widget.value;
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'[AutocompleteDropdownField:didUpdateWidget] label=[33m${widget.label}[0m, value 동기화: widget.value=${widget.value}, controller.text=${_controller.text}',
|
||||
);
|
||||
}
|
||||
if (widget.items != oldWidget.items) {
|
||||
_filteredItems = List.from(widget.items);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNode.dispose();
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleFocusChange() {
|
||||
setState(() {
|
||||
// 포커스가 있고 필터링된 아이템이 있을 때만 드롭다운 표시
|
||||
_showDropdown = _focusNode.hasFocus && _filteredItems.isNotEmpty;
|
||||
|
||||
// 포커스가 없으면 드롭다운 닫기
|
||||
if (!_focusNode.hasFocus) {
|
||||
_showDropdown = false;
|
||||
}
|
||||
});
|
||||
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'[AutocompleteDropdownField:_handleFocusChange] label=${widget.label}, hasFocus=${_focusNode.hasFocus}, showDropdown=$_showDropdown',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onTextChanged() {
|
||||
final text = _controller.text;
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'[AutocompleteDropdownField:_onTextChanged] label=${widget.label}, text=$text',
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
if (text.isEmpty) {
|
||||
_filteredItems = List.from(widget.items);
|
||||
} else {
|
||||
_filteredItems =
|
||||
widget.items
|
||||
.where(
|
||||
(item) => item.toLowerCase().contains(text.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
// 일치하는 아이템 정렬 (시작 부분 일치 항목 우선)
|
||||
_filteredItems.sort((a, b) {
|
||||
bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase());
|
||||
bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase());
|
||||
if (aStartsWith && !bStartsWith) return -1;
|
||||
if (!aStartsWith && bStartsWith) return 1;
|
||||
return a.compareTo(b);
|
||||
});
|
||||
}
|
||||
// 포커스가 있고 필터링된 아이템이 있을 때만 드롭다운 표시
|
||||
_showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus;
|
||||
widget.onChanged(text);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleSelect(String value) {
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'[AutocompleteDropdownField:_handleSelect] 선택값=$value, 이전 값=${_controller.text}',
|
||||
);
|
||||
}
|
||||
// 1. 값 전달 (부모 콜백)
|
||||
widget.onChanged(value); // 입력값 변경 콜백
|
||||
widget.onSelected(value); // 선택 콜백
|
||||
// 2. 부모 setState 이후, 프레임이 끝난 뒤 드롭다운 닫기 (즉각 반영 보장)
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() {
|
||||
_controller.text = value;
|
||||
_controller.selection = TextSelection.collapsed(offset: value.length);
|
||||
_showDropdown = false;
|
||||
});
|
||||
_focusNode.unfocus();
|
||||
});
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'[AutocompleteDropdownField:_handleSelect] 업데이트 완료: controller.text=${_controller.text}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleDropdown() {
|
||||
setState(() {
|
||||
_showDropdown = !_showDropdown && _filteredItems.isNotEmpty;
|
||||
if (_showDropdown) {
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
key: _fieldKey, // 고정된 키 사용
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
enabled: widget.enabled,
|
||||
decoration: InputDecoration(
|
||||
labelText: widget.label,
|
||||
hintText: widget.hintText,
|
||||
suffixIcon: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_controller.text.isNotEmpty)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed:
|
||||
widget.enabled
|
||||
? () {
|
||||
setState(() {
|
||||
_controller.clear();
|
||||
_filteredItems = List.from(widget.items);
|
||||
_showDropdown =
|
||||
_focusNode.hasFocus &&
|
||||
_filteredItems.isNotEmpty;
|
||||
widget.onSelected('');
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
onPressed: widget.enabled ? _toggleDropdown : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (widget.isRequired && (value == null || value.isEmpty)) {
|
||||
return '${widget.label}을(를) 입력해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onSaved: (value) {
|
||||
widget.onSelected(value ?? '');
|
||||
},
|
||||
),
|
||||
if (_showDropdown)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 56, // TextFormField 높이만큼 아래로
|
||||
child: Material(
|
||||
elevation: 4,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 200),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _filteredItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
if (kDebugMode) {
|
||||
print(
|
||||
'[AutocompleteDropdownField:GestureDetector:onTap] label=${widget.label}, 선택값=${_filteredItems[index]}',
|
||||
);
|
||||
}
|
||||
_handleSelect(_filteredItems[index]);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
child: Text(_filteredItems[index]),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
186
lib/screens/common/widgets/category_autocomplete_field.dart
Normal file
186
lib/screens/common/widgets/category_autocomplete_field.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../custom_widgets.dart'; // AutocompleteDropdown, HighlightText 등 사용
|
||||
|
||||
// 입력 필드 + 자동완성 드롭다운을 하나로 묶은 공통 위젯
|
||||
class CategoryAutocompleteField extends StatefulWidget {
|
||||
// 입력 필드의 힌트 텍스트
|
||||
final String hintText;
|
||||
// 현재 선택된 값
|
||||
final String value;
|
||||
// 항목 리스트
|
||||
final List<String> items;
|
||||
// 필수 입력 여부
|
||||
final bool isRequired;
|
||||
// 선택 시 콜백
|
||||
final void Function(String) onSelect;
|
||||
// 입력값 변경 시 콜백(옵션)
|
||||
final void Function(String)? onChanged;
|
||||
// 비활성화 여부
|
||||
final bool enabled;
|
||||
|
||||
const CategoryAutocompleteField({
|
||||
Key? key,
|
||||
required this.hintText,
|
||||
required this.value,
|
||||
required this.items,
|
||||
required this.onSelect,
|
||||
this.isRequired = false,
|
||||
this.onChanged,
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CategoryAutocompleteField> createState() =>
|
||||
_CategoryAutocompleteFieldState();
|
||||
}
|
||||
|
||||
class _CategoryAutocompleteFieldState extends State<CategoryAutocompleteField> {
|
||||
// 텍스트 입력 컨트롤러
|
||||
late final TextEditingController _controller;
|
||||
// 포커스 노드
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
// 드롭다운 표시 여부
|
||||
bool _showDropdown = false;
|
||||
// 필터링된 항목 리스트
|
||||
List<String> _filteredItems = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(text: widget.value);
|
||||
_filteredItems = List.from(widget.items);
|
||||
_controller.addListener(_onTextChanged);
|
||||
_focusNode.addListener(() {
|
||||
setState(() {
|
||||
if (_focusNode.hasFocus) {
|
||||
_showDropdown = _filteredItems.isNotEmpty;
|
||||
} else {
|
||||
_showDropdown = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant CategoryAutocompleteField oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.value != _controller.text) {
|
||||
_controller.text = widget.value;
|
||||
}
|
||||
if (widget.items != oldWidget.items) {
|
||||
_filteredItems = List.from(widget.items);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 입력값 변경 시 필터링
|
||||
void _onTextChanged() {
|
||||
final String text = _controller.text;
|
||||
setState(() {
|
||||
if (text.isEmpty) {
|
||||
_filteredItems = List.from(widget.items);
|
||||
} else {
|
||||
_filteredItems =
|
||||
widget.items
|
||||
.where(
|
||||
(item) => item.toLowerCase().contains(text.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
// 시작 부분이 일치하는 항목 우선 정렬
|
||||
_filteredItems.sort((a, b) {
|
||||
bool aStartsWith = a.toLowerCase().startsWith(text.toLowerCase());
|
||||
bool bStartsWith = b.toLowerCase().startsWith(text.toLowerCase());
|
||||
if (aStartsWith && !bStartsWith) return -1;
|
||||
if (!aStartsWith && bStartsWith) return 1;
|
||||
return a.compareTo(b);
|
||||
});
|
||||
}
|
||||
_showDropdown = _filteredItems.isNotEmpty && _focusNode.hasFocus;
|
||||
if (widget.onChanged != null) {
|
||||
widget.onChanged!(text);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 항목 선택 시 처리
|
||||
void _handleSelect(String value) {
|
||||
setState(() {
|
||||
_controller.text = value;
|
||||
_showDropdown = false;
|
||||
});
|
||||
widget.onSelect(value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _controller,
|
||||
focusNode: _focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: widget.hintText,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
suffixIcon:
|
||||
_controller.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed:
|
||||
widget.enabled
|
||||
? () {
|
||||
setState(() {
|
||||
_controller.clear();
|
||||
_filteredItems = List.from(widget.items);
|
||||
_showDropdown = _focusNode.hasFocus;
|
||||
widget.onSelect('');
|
||||
});
|
||||
}
|
||||
: null,
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
onPressed:
|
||||
widget.enabled
|
||||
? () {
|
||||
setState(() {
|
||||
_showDropdown = !_showDropdown;
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
),
|
||||
enabled: widget.enabled,
|
||||
validator: (value) {
|
||||
if (widget.isRequired && (value == null || value.isEmpty)) {
|
||||
return '${widget.hintText}를 선택해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (!_showDropdown) {
|
||||
_showDropdown = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
AutocompleteDropdown(
|
||||
items: _filteredItems,
|
||||
inputText: _controller.text,
|
||||
onSelect: _handleSelect,
|
||||
showDropdown: _showDropdown,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
76
lib/screens/common/widgets/company_branch_dropdown.dart
Normal file
76
lib/screens/common/widgets/company_branch_dropdown.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
// 회사/지점 드롭다운 공통 위젯
|
||||
// 여러 도메인에서 재사용 가능
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../models/company_model.dart';
|
||||
|
||||
class CompanyBranchDropdown extends StatelessWidget {
|
||||
final List<Company> companies;
|
||||
final int? selectedCompanyId;
|
||||
final int? selectedBranchId;
|
||||
final List<Branch> branches;
|
||||
final void Function(int? companyId) onCompanyChanged;
|
||||
final void Function(int? branchId) onBranchChanged;
|
||||
|
||||
const CompanyBranchDropdown({
|
||||
super.key,
|
||||
required this.companies,
|
||||
required this.selectedCompanyId,
|
||||
required this.selectedBranchId,
|
||||
required this.branches,
|
||||
required this.onCompanyChanged,
|
||||
required this.onBranchChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 회사 드롭다운
|
||||
DropdownButtonFormField<int>(
|
||||
value: selectedCompanyId,
|
||||
decoration: const InputDecoration(hintText: '소속 회사를 선택하세요'),
|
||||
items:
|
||||
companies
|
||||
.map(
|
||||
(company) => DropdownMenuItem<int>(
|
||||
value: company.id,
|
||||
child: Text(company.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: onCompanyChanged,
|
||||
validator: (value) {
|
||||
if (value == null) {
|
||||
return '소속 회사를 선택해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
// 지점 드롭다운 (지점이 있을 때만)
|
||||
if (branches.isNotEmpty)
|
||||
DropdownButtonFormField<int>(
|
||||
value: selectedBranchId,
|
||||
decoration: const InputDecoration(hintText: '소속 지점을 선택하세요'),
|
||||
items:
|
||||
branches
|
||||
.map(
|
||||
(branch) => DropdownMenuItem<int>(
|
||||
value: branch.id,
|
||||
child: Text(branch.name),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onChanged: onBranchChanged,
|
||||
validator: (value) {
|
||||
if (branches.isNotEmpty && value == null) {
|
||||
return '소속 지점을 선택해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
88
lib/screens/common/widgets/pagination.dart
Normal file
88
lib/screens/common/widgets/pagination.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 페이지네이션 위젯 (<< < 1 2 3 ... 10 > >>)
|
||||
/// - totalCount: 전체 아이템 수
|
||||
/// - currentPage: 현재 페이지 (1부터 시작)
|
||||
/// - pageSize: 페이지당 아이템 수
|
||||
/// - onPageChanged: 페이지 변경 콜백
|
||||
class Pagination extends StatelessWidget {
|
||||
final int totalCount;
|
||||
final int currentPage;
|
||||
final int pageSize;
|
||||
final ValueChanged<int> onPageChanged;
|
||||
|
||||
const Pagination({
|
||||
Key? key,
|
||||
required this.totalCount,
|
||||
required this.currentPage,
|
||||
required this.pageSize,
|
||||
required this.onPageChanged,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 전체 페이지 수 계산
|
||||
final int totalPages = (totalCount / pageSize).ceil();
|
||||
// 페이지네이션 버튼 최대 10개
|
||||
final int maxButtons = 10;
|
||||
// 시작 페이지 계산
|
||||
int startPage = ((currentPage - 1) ~/ maxButtons) * maxButtons + 1;
|
||||
int endPage = (startPage + maxButtons - 1).clamp(1, totalPages);
|
||||
|
||||
List<Widget> pageButtons = [];
|
||||
for (int i = startPage; i <= endPage; i++) {
|
||||
pageButtons.add(
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
minimumSize: const Size(36, 36),
|
||||
backgroundColor: i == currentPage ? Colors.blue : Colors.white,
|
||||
foregroundColor: i == currentPage ? Colors.white : Colors.black,
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
onPressed: i == currentPage ? null : () => onPageChanged(i),
|
||||
child: Text('$i'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 가장 처음 페이지로 이동
|
||||
IconButton(
|
||||
icon: const Icon(Icons.first_page),
|
||||
tooltip: '처음',
|
||||
onPressed: currentPage > 1 ? () => onPageChanged(1) : null,
|
||||
),
|
||||
// 이전 페이지로 이동
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
tooltip: '이전',
|
||||
onPressed:
|
||||
currentPage > 1 ? () => onPageChanged(currentPage - 1) : null,
|
||||
),
|
||||
// 페이지 번호 버튼들
|
||||
...pageButtons,
|
||||
// 다음 페이지로 이동
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right),
|
||||
tooltip: '다음',
|
||||
onPressed:
|
||||
currentPage < totalPages
|
||||
? () => onPageChanged(currentPage + 1)
|
||||
: null,
|
||||
),
|
||||
// 마지막 페이지로 이동
|
||||
IconButton(
|
||||
icon: const Icon(Icons.last_page),
|
||||
tooltip: '마지막',
|
||||
onPressed:
|
||||
currentPage < totalPages ? () => onPageChanged(totalPages) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
40
lib/screens/common/widgets/remark_input.dart
Normal file
40
lib/screens/common/widgets/remark_input.dart
Normal file
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 공통 비고 입력 위젯
|
||||
/// 여러 화면에서 재사용할 수 있도록 설계
|
||||
class RemarkInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String hint;
|
||||
final FormFieldValidator<String>? validator;
|
||||
final int minLines;
|
||||
final int? maxLines;
|
||||
final bool enabled;
|
||||
|
||||
const RemarkInput({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
this.label = '비고',
|
||||
this.hint = '비고를 입력하세요',
|
||||
this.validator,
|
||||
this.minLines = 4,
|
||||
this.maxLines,
|
||||
this.enabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
minLines: minLines,
|
||||
maxLines: maxLines,
|
||||
enabled: enabled,
|
||||
validator: validator,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user