프로젝트 최초 커밋
This commit is contained in:
83
lib/screens/common/app_layout.dart
Normal file
83
lib/screens/common/app_layout.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/sidebar/sidebar_screen.dart';
|
||||
import 'package:superport/screens/overview/overview_screen.dart';
|
||||
import 'package:superport/screens/equipment/equipment_list.dart';
|
||||
import 'package:superport/screens/company/company_list.dart';
|
||||
import 'package:superport/screens/user/user_list.dart';
|
||||
import 'package:superport/screens/license/license_list.dart';
|
||||
import 'package:superport/screens/warehouse_location/warehouse_location_list.dart';
|
||||
import 'package:superport/screens/goods/goods_list.dart';
|
||||
import 'package:superport/utils/constants.dart';
|
||||
|
||||
/// SPA 스타일의 앱 레이아웃 클래스
|
||||
/// 사이드바는 고정되고 내용만 변경되는 구조를 제공
|
||||
class AppLayout extends StatefulWidget {
|
||||
final String initialRoute;
|
||||
|
||||
const AppLayout({Key? key, this.initialRoute = Routes.home})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
_AppLayoutState createState() => _AppLayoutState();
|
||||
}
|
||||
|
||||
class _AppLayoutState extends State<AppLayout> {
|
||||
late String _currentRoute;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_currentRoute = widget.initialRoute;
|
||||
}
|
||||
|
||||
/// 현재 경로에 따라 적절한 컨텐츠 섹션을 반환
|
||||
Widget _getContentForRoute(String route) {
|
||||
switch (route) {
|
||||
case Routes.home:
|
||||
return const OverviewScreen();
|
||||
case Routes.equipment:
|
||||
case Routes.equipmentInList:
|
||||
case Routes.equipmentOutList:
|
||||
case Routes.equipmentRentList:
|
||||
// 장비 목록 화면에 현재 라우트 정보를 전달
|
||||
return EquipmentListScreen(currentRoute: route);
|
||||
case Routes.goods:
|
||||
return const GoodsListScreen();
|
||||
case Routes.company:
|
||||
return const CompanyListScreen();
|
||||
case Routes.license:
|
||||
return const MaintenanceListScreen();
|
||||
case Routes.warehouseLocation:
|
||||
return const WarehouseLocationListScreen();
|
||||
default:
|
||||
return const OverviewScreen();
|
||||
}
|
||||
}
|
||||
|
||||
/// 경로 변경 메서드
|
||||
void _navigateTo(String route) {
|
||||
setState(() {
|
||||
_currentRoute = route;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Row(
|
||||
children: [
|
||||
// 왼쪽 사이드바
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: SidebarMenu(
|
||||
currentRoute: _currentRoute,
|
||||
onRouteChanged: _navigateTo,
|
||||
),
|
||||
),
|
||||
// 오른쪽 컨텐츠 영역
|
||||
Expanded(child: _getContentForRoute(_currentRoute)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
8
lib/screens/common/custom_widgets.dart
Normal file
8
lib/screens/common/custom_widgets.dart
Normal file
@@ -0,0 +1,8 @@
|
||||
export 'custom_widgets/page_title.dart';
|
||||
export 'custom_widgets/data_table_card.dart';
|
||||
export 'custom_widgets/form_field_wrapper.dart';
|
||||
export 'custom_widgets/date_picker_field.dart';
|
||||
export 'custom_widgets/highlight_text.dart';
|
||||
export 'custom_widgets/autocomplete_dropdown.dart';
|
||||
export 'custom_widgets/category_selection_field.dart';
|
||||
export 'custom_widgets/category_data.dart';
|
||||
88
lib/screens/common/custom_widgets/autocomplete_dropdown.dart
Normal file
88
lib/screens/common/custom_widgets/autocomplete_dropdown.dart
Normal file
@@ -0,0 +1,88 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'highlight_text.dart';
|
||||
|
||||
// 자동완성 드롭다운 공통 위젯
|
||||
class AutocompleteDropdown extends StatelessWidget {
|
||||
// 드롭다운에 표시할 항목 리스트
|
||||
final List<String> items;
|
||||
// 현재 입력된 텍스트(하이라이트 기준)
|
||||
final String inputText;
|
||||
// 항목 선택 시 콜백
|
||||
final void Function(String) onSelect;
|
||||
// 드롭다운 표시 여부
|
||||
final bool showDropdown;
|
||||
// 최대 높이(항목 개수에 따라 자동 조절)
|
||||
final double maxHeight;
|
||||
// 드롭다운이 비었을 때 표시할 위젯
|
||||
final Widget emptyWidget;
|
||||
|
||||
const AutocompleteDropdown({
|
||||
Key? key,
|
||||
required this.items,
|
||||
required this.inputText,
|
||||
required this.onSelect,
|
||||
required this.showDropdown,
|
||||
this.maxHeight = 200,
|
||||
this.emptyWidget = const Padding(
|
||||
padding: EdgeInsets.all(12.0),
|
||||
child: Text('검색 결과가 없습니다'),
|
||||
),
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height:
|
||||
showDropdown
|
||||
? (items.length > 4 ? maxHeight : items.length * 50.0)
|
||||
: 0,
|
||||
margin: EdgeInsets.only(top: showDropdown ? 4 : 0),
|
||||
child: SingleChildScrollView(
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: GestureDetector(
|
||||
onTap: () {}, // 이벤트 버블링 방지
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withAlpha(77),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 3,
|
||||
offset: const Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
child:
|
||||
items.isEmpty
|
||||
? emptyWidget
|
||||
: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: items.length,
|
||||
separatorBuilder:
|
||||
(context, index) =>
|
||||
Divider(height: 1, color: Colors.grey.shade200),
|
||||
itemBuilder: (context, index) {
|
||||
final String item = items[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
title: HighlightText(
|
||||
text: item,
|
||||
highlight: inputText,
|
||||
highlightColor: Theme.of(context).primaryColor,
|
||||
),
|
||||
onTap: () => onSelect(item),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
18
lib/screens/common/custom_widgets/category_data.dart
Normal file
18
lib/screens/common/custom_widgets/category_data.dart
Normal file
@@ -0,0 +1,18 @@
|
||||
// 카테고리 데이터 (예시)
|
||||
final Map<String, Map<String, List<String>>> categoryData = {
|
||||
'컴퓨터': {
|
||||
'데스크탑': ['사무용', '게이밍', '워크스테이션'],
|
||||
'노트북': ['사무용', '게이밍', '울트라북'],
|
||||
'태블릿': ['안드로이드', 'iOS', '윈도우'],
|
||||
},
|
||||
'네트워크': {
|
||||
'라우터': ['가정용', '기업용', '산업용'],
|
||||
'스위치': ['관리형', '비관리형'],
|
||||
'액세스 포인트': ['실내용', '실외용'],
|
||||
},
|
||||
'주변기기': {
|
||||
'모니터': ['LCD', 'LED', 'OLED'],
|
||||
'키보드': ['유선', '무선', '기계식'],
|
||||
'마우스': ['유선', '무선', '트랙볼'],
|
||||
},
|
||||
};
|
||||
562
lib/screens/common/custom_widgets/category_selection_field.dart
Normal file
562
lib/screens/common/custom_widgets/category_selection_field.dart
Normal file
@@ -0,0 +1,562 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'autocomplete_dropdown.dart';
|
||||
import 'form_field_wrapper.dart';
|
||||
import 'category_data.dart';
|
||||
|
||||
// 카테고리 선택 필드 (대분류/중분류/소분류)
|
||||
class CategorySelectionField extends StatefulWidget {
|
||||
final String category;
|
||||
final String subCategory;
|
||||
final String subSubCategory;
|
||||
final Function(String, String, String) onCategoryChanged;
|
||||
final bool isRequired;
|
||||
|
||||
const CategorySelectionField({
|
||||
Key? key,
|
||||
required this.category,
|
||||
required this.subCategory,
|
||||
required this.subSubCategory,
|
||||
required this.onCategoryChanged,
|
||||
this.isRequired = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CategorySelectionField> createState() => _CategorySelectionFieldState();
|
||||
}
|
||||
|
||||
class _CategorySelectionFieldState extends State<CategorySelectionField> {
|
||||
// 검색 관련 컨트롤러 및 상태 변수
|
||||
final TextEditingController _categoryController = TextEditingController();
|
||||
final FocusNode _categoryFocusNode = FocusNode();
|
||||
bool _showCategoryDropdown = false;
|
||||
List<String> _filteredCategories = [];
|
||||
|
||||
// 중분류 관련 변수
|
||||
final TextEditingController _subCategoryController = TextEditingController();
|
||||
final FocusNode _subCategoryFocusNode = FocusNode();
|
||||
bool _showSubCategoryDropdown = false;
|
||||
List<String> _filteredSubCategories = [];
|
||||
|
||||
// 소분류 관련 변수
|
||||
final TextEditingController _subSubCategoryController =
|
||||
TextEditingController();
|
||||
final FocusNode _subSubCategoryFocusNode = FocusNode();
|
||||
bool _showSubSubCategoryDropdown = false;
|
||||
List<String> _filteredSubSubCategories = [];
|
||||
|
||||
List<String> _allCategories = [];
|
||||
String _selectedCategory = '';
|
||||
String _selectedSubCategory = '';
|
||||
String _selectedSubSubCategory = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedCategory = widget.category;
|
||||
_selectedSubCategory = widget.subCategory;
|
||||
_selectedSubSubCategory = widget.subSubCategory;
|
||||
_categoryController.text = _selectedCategory;
|
||||
_subCategoryController.text = _selectedSubCategory;
|
||||
_subSubCategoryController.text = _selectedSubSubCategory;
|
||||
|
||||
// 모든 카테고리 목록 초기화
|
||||
_allCategories = categoryData.keys.toList();
|
||||
_filteredCategories = List.from(_allCategories);
|
||||
|
||||
// 중분류 목록 초기화
|
||||
_updateSubCategories();
|
||||
|
||||
// 소분류 목록 초기화
|
||||
_updateSubSubCategories();
|
||||
|
||||
// 대분류 컨트롤러 리스너 설정
|
||||
_categoryController.addListener(_onCategoryTextChanged);
|
||||
_categoryFocusNode.addListener(() {
|
||||
setState(() {
|
||||
if (_categoryFocusNode.hasFocus) {
|
||||
_showCategoryDropdown = _filteredCategories.isNotEmpty;
|
||||
} else {
|
||||
_showCategoryDropdown = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 중분류 컨트롤러 리스너 설정
|
||||
_subCategoryController.addListener(_onSubCategoryTextChanged);
|
||||
_subCategoryFocusNode.addListener(() {
|
||||
setState(() {
|
||||
if (_subCategoryFocusNode.hasFocus) {
|
||||
_showSubCategoryDropdown = _filteredSubCategories.isNotEmpty;
|
||||
} else {
|
||||
_showSubCategoryDropdown = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// 소분류 컨트롤러 리스너 설정
|
||||
_subSubCategoryController.addListener(_onSubSubCategoryTextChanged);
|
||||
_subSubCategoryFocusNode.addListener(() {
|
||||
setState(() {
|
||||
if (_subSubCategoryFocusNode.hasFocus) {
|
||||
_showSubSubCategoryDropdown = _filteredSubSubCategories.isNotEmpty;
|
||||
} else {
|
||||
_showSubSubCategoryDropdown = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_categoryController.dispose();
|
||||
_categoryFocusNode.dispose();
|
||||
_subCategoryController.dispose();
|
||||
_subCategoryFocusNode.dispose();
|
||||
_subSubCategoryController.dispose();
|
||||
_subSubCategoryFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 중분류 목록 업데이트
|
||||
void _updateSubCategories() {
|
||||
if (_selectedCategory.isNotEmpty) {
|
||||
final subCategories =
|
||||
categoryData[_selectedCategory]?.keys.toList() ?? [];
|
||||
_filteredSubCategories = List.from(subCategories);
|
||||
} else {
|
||||
_filteredSubCategories = [];
|
||||
}
|
||||
}
|
||||
|
||||
// 소분류 목록 업데이트
|
||||
void _updateSubSubCategories() {
|
||||
if (_selectedCategory.isNotEmpty && _selectedSubCategory.isNotEmpty) {
|
||||
final subSubCategories =
|
||||
categoryData[_selectedCategory]?[_selectedSubCategory] ?? [];
|
||||
_filteredSubSubCategories = List.from(subSubCategories);
|
||||
} else {
|
||||
_filteredSubSubCategories = [];
|
||||
}
|
||||
}
|
||||
|
||||
void _onCategoryTextChanged() {
|
||||
final text = _categoryController.text;
|
||||
setState(() {
|
||||
_selectedCategory = text;
|
||||
|
||||
if (text.isEmpty) {
|
||||
_filteredCategories = List.from(_allCategories);
|
||||
} else {
|
||||
_filteredCategories =
|
||||
_allCategories
|
||||
.where(
|
||||
(item) => item.toLowerCase().contains(text.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// 시작 부분이 일치하는 항목 우선 정렬
|
||||
_filteredCategories.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);
|
||||
});
|
||||
}
|
||||
|
||||
_showCategoryDropdown =
|
||||
_filteredCategories.isNotEmpty && _categoryFocusNode.hasFocus;
|
||||
|
||||
// 카테고리가 변경되면 하위 카테고리 초기화
|
||||
if (_selectedCategory != widget.category) {
|
||||
_selectedSubCategory = '';
|
||||
_subCategoryController.text = '';
|
||||
_selectedSubSubCategory = '';
|
||||
_subSubCategoryController.text = '';
|
||||
_updateSubCategories();
|
||||
_updateSubSubCategories();
|
||||
}
|
||||
|
||||
// 콜백 호출
|
||||
widget.onCategoryChanged(
|
||||
_selectedCategory,
|
||||
_selectedSubCategory,
|
||||
_selectedSubSubCategory,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 중분류 텍스트 변경 핸들러
|
||||
void _onSubCategoryTextChanged() {
|
||||
final text = _subCategoryController.text;
|
||||
setState(() {
|
||||
_selectedSubCategory = text;
|
||||
|
||||
if (_selectedCategory.isNotEmpty) {
|
||||
final subCategories =
|
||||
categoryData[_selectedCategory]?.keys.toList() ?? [];
|
||||
|
||||
if (text.isEmpty) {
|
||||
_filteredSubCategories = List.from(subCategories);
|
||||
} else {
|
||||
_filteredSubCategories =
|
||||
subCategories
|
||||
.where(
|
||||
(item) => item.toLowerCase().contains(text.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// 시작 부분이 일치하는 항목 우선 정렬
|
||||
_filteredSubCategories.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);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_filteredSubCategories = [];
|
||||
}
|
||||
|
||||
_showSubCategoryDropdown =
|
||||
_filteredSubCategories.isNotEmpty && _subCategoryFocusNode.hasFocus;
|
||||
|
||||
// 중분류가 변경되면 소분류 초기화
|
||||
if (_selectedSubCategory != widget.subCategory) {
|
||||
_selectedSubSubCategory = '';
|
||||
_subSubCategoryController.text = '';
|
||||
_updateSubSubCategories();
|
||||
}
|
||||
|
||||
// 콜백 호출
|
||||
widget.onCategoryChanged(
|
||||
_selectedCategory,
|
||||
_selectedSubCategory,
|
||||
_selectedSubSubCategory,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 소분류 텍스트 변경 핸들러
|
||||
void _onSubSubCategoryTextChanged() {
|
||||
final text = _subSubCategoryController.text;
|
||||
setState(() {
|
||||
_selectedSubSubCategory = text;
|
||||
|
||||
if (_selectedCategory.isNotEmpty && _selectedSubCategory.isNotEmpty) {
|
||||
final subSubCategories =
|
||||
categoryData[_selectedCategory]?[_selectedSubCategory] ?? [];
|
||||
|
||||
if (text.isEmpty) {
|
||||
_filteredSubSubCategories = List.from(subSubCategories);
|
||||
} else {
|
||||
_filteredSubSubCategories =
|
||||
subSubCategories
|
||||
.where(
|
||||
(item) => item.toLowerCase().contains(text.toLowerCase()),
|
||||
)
|
||||
.toList();
|
||||
|
||||
// 시작 부분이 일치하는 항목 우선 정렬
|
||||
_filteredSubSubCategories.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);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_filteredSubSubCategories = [];
|
||||
}
|
||||
|
||||
_showSubSubCategoryDropdown =
|
||||
_filteredSubSubCategories.isNotEmpty &&
|
||||
_subSubCategoryFocusNode.hasFocus;
|
||||
|
||||
// 콜백 호출
|
||||
widget.onCategoryChanged(
|
||||
_selectedCategory,
|
||||
_selectedSubCategory,
|
||||
_selectedSubSubCategory,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void _selectCategory(String category) {
|
||||
setState(() {
|
||||
_selectedCategory = category;
|
||||
_categoryController.text = category;
|
||||
_showCategoryDropdown = false;
|
||||
_selectedSubCategory = '';
|
||||
_subCategoryController.text = '';
|
||||
_selectedSubSubCategory = '';
|
||||
_subSubCategoryController.text = '';
|
||||
_updateSubCategories();
|
||||
_updateSubSubCategories();
|
||||
widget.onCategoryChanged(
|
||||
_selectedCategory,
|
||||
_selectedSubCategory,
|
||||
_selectedSubSubCategory,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 중분류 선택 핸들러
|
||||
void _selectSubCategory(String subCategory) {
|
||||
setState(() {
|
||||
_selectedSubCategory = subCategory;
|
||||
_subCategoryController.text = subCategory;
|
||||
_showSubCategoryDropdown = false;
|
||||
_selectedSubSubCategory = '';
|
||||
_subSubCategoryController.text = '';
|
||||
_updateSubSubCategories();
|
||||
widget.onCategoryChanged(
|
||||
_selectedCategory,
|
||||
_selectedSubCategory,
|
||||
_selectedSubSubCategory,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 소분류 선택 핸들러
|
||||
void _selectSubSubCategory(String subSubCategory) {
|
||||
setState(() {
|
||||
_selectedSubSubCategory = subSubCategory;
|
||||
_subSubCategoryController.text = subSubCategory;
|
||||
_showSubSubCategoryDropdown = false;
|
||||
widget.onCategoryChanged(
|
||||
_selectedCategory,
|
||||
_selectedSubCategory,
|
||||
_selectedSubSubCategory,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FormFieldWrapper(
|
||||
label: '카테고리',
|
||||
isRequired: widget.isRequired,
|
||||
child: Column(
|
||||
children: [
|
||||
// 대분류 입력 필드 (자동완성)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _categoryController,
|
||||
focusNode: _categoryFocusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: '대분류',
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
suffixIcon:
|
||||
_categoryController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_categoryController.clear();
|
||||
_selectedCategory = '';
|
||||
_selectedSubCategory = '';
|
||||
_selectedSubSubCategory = '';
|
||||
_subCategoryController.clear();
|
||||
_subSubCategoryController.clear();
|
||||
_filteredCategories = List.from(_allCategories);
|
||||
_filteredSubCategories = [];
|
||||
_filteredSubSubCategories = [];
|
||||
_showCategoryDropdown =
|
||||
_categoryFocusNode.hasFocus;
|
||||
widget.onCategoryChanged('', '', '');
|
||||
});
|
||||
},
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showCategoryDropdown = !_showCategoryDropdown;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
validator: (value) {
|
||||
if (widget.isRequired && (value == null || value.isEmpty)) {
|
||||
return '대분류를 선택해주세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (!_showCategoryDropdown) {
|
||||
_showCategoryDropdown = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// 대분류 자동완성 드롭다운
|
||||
AutocompleteDropdown(
|
||||
items: _filteredCategories,
|
||||
inputText: _categoryController.text,
|
||||
onSelect: _selectCategory,
|
||||
showDropdown: _showCategoryDropdown,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 중분류 및 소분류 선택 행
|
||||
Row(
|
||||
children: [
|
||||
// 중분류 입력 필드 (자동완성)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _subCategoryController,
|
||||
focusNode: _subCategoryFocusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: '중분류',
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
suffixIcon:
|
||||
_subCategoryController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_subCategoryController.clear();
|
||||
_selectedSubCategory = '';
|
||||
_selectedSubSubCategory = '';
|
||||
_subSubCategoryController.clear();
|
||||
_updateSubCategories();
|
||||
_updateSubSubCategories();
|
||||
_showSubCategoryDropdown =
|
||||
_subCategoryFocusNode.hasFocus;
|
||||
widget.onCategoryChanged(
|
||||
_selectedCategory,
|
||||
'',
|
||||
'',
|
||||
);
|
||||
});
|
||||
},
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showSubCategoryDropdown =
|
||||
!_showSubCategoryDropdown;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
enabled: _selectedCategory.isNotEmpty,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (!_showSubCategoryDropdown &&
|
||||
_filteredSubCategories.isNotEmpty) {
|
||||
_showSubCategoryDropdown = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// 중분류 자동완성 드롭다운
|
||||
AutocompleteDropdown(
|
||||
items: _filteredSubCategories,
|
||||
inputText: _subCategoryController.text,
|
||||
onSelect: _selectSubCategory,
|
||||
showDropdown: _showSubCategoryDropdown,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 소분류 입력 필드 (자동완성)
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _subSubCategoryController,
|
||||
focusNode: _subSubCategoryFocusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: '소분류',
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
suffixIcon:
|
||||
_subSubCategoryController.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_subSubCategoryController.clear();
|
||||
_selectedSubSubCategory = '';
|
||||
_updateSubSubCategories();
|
||||
_showSubSubCategoryDropdown =
|
||||
_subSubCategoryFocusNode.hasFocus;
|
||||
widget.onCategoryChanged(
|
||||
_selectedCategory,
|
||||
_selectedSubCategory,
|
||||
'',
|
||||
);
|
||||
});
|
||||
},
|
||||
)
|
||||
: IconButton(
|
||||
icon: const Icon(Icons.arrow_drop_down),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_showSubSubCategoryDropdown =
|
||||
!_showSubSubCategoryDropdown;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
enabled:
|
||||
_selectedCategory.isNotEmpty &&
|
||||
_selectedSubCategory.isNotEmpty,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (!_showSubSubCategoryDropdown &&
|
||||
_filteredSubSubCategories.isNotEmpty) {
|
||||
_showSubSubCategoryDropdown = true;
|
||||
}
|
||||
});
|
||||
},
|
||||
),
|
||||
|
||||
// 소분류 자동완성 드롭다운
|
||||
AutocompleteDropdown(
|
||||
items: _filteredSubSubCategories,
|
||||
inputText: _subSubCategoryController.text,
|
||||
onSelect: _selectSubSubCategory,
|
||||
showDropdown: _showSubSubCategoryDropdown,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
32
lib/screens/common/custom_widgets/data_table_card.dart
Normal file
32
lib/screens/common/custom_widgets/data_table_card.dart
Normal file
@@ -0,0 +1,32 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
// 데이터 테이블 카드
|
||||
class DataTableCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final String? title;
|
||||
final double? width;
|
||||
|
||||
const DataTableCard({Key? key, required this.child, this.title, this.width})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
decoration: AppThemeTailwind.cardDecoration,
|
||||
margin: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(title!, style: AppThemeTailwind.subheadingStyle),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.all(16.0), child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/screens/common/custom_widgets/date_picker_field.dart
Normal file
57
lib/screens/common/custom_widgets/date_picker_field.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'form_field_wrapper.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
// 날짜 선택 필드
|
||||
class DatePickerField extends StatelessWidget {
|
||||
final DateTime selectedDate;
|
||||
final Function(DateTime) onDateChanged;
|
||||
final bool allowFutureDate;
|
||||
final bool isRequired;
|
||||
|
||||
const DatePickerField({
|
||||
Key? key,
|
||||
required this.selectedDate,
|
||||
required this.onDateChanged,
|
||||
this.allowFutureDate = false,
|
||||
this.isRequired = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: () async {
|
||||
final DateTime? picked = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: selectedDate,
|
||||
firstDate: DateTime(2000),
|
||||
lastDate: allowFutureDate ? DateTime(2100) : DateTime.now(),
|
||||
);
|
||||
if (picked != null && picked != selectedDate) {
|
||||
onDateChanged(picked);
|
||||
}
|
||||
},
|
||||
child: FormFieldWrapper(
|
||||
label: '날짜',
|
||||
isRequired: isRequired,
|
||||
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(
|
||||
'${selectedDate.year}-${selectedDate.month.toString().padLeft(2, '0')}-${selectedDate.day.toString().padLeft(2, '0')}',
|
||||
style: AppThemeTailwind.bodyStyle,
|
||||
),
|
||||
const Icon(Icons.calendar_today, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/screens/common/custom_widgets/form_field_wrapper.dart
Normal file
48
lib/screens/common/custom_widgets/form_field_wrapper.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// 폼 필드 래퍼
|
||||
class FormFieldWrapper extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool isRequired;
|
||||
|
||||
const FormFieldWrapper({
|
||||
Key? key,
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.isRequired = false,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (isRequired)
|
||||
const Text(
|
||||
' *',
|
||||
style: TextStyle(
|
||||
color: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8.0),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
lib/screens/common/custom_widgets/highlight_text.dart
Normal file
53
lib/screens/common/custom_widgets/highlight_text.dart
Normal file
@@ -0,0 +1,53 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
// 자동완성 드롭다운에서 텍스트 하이라이트를 위한 위젯
|
||||
class HighlightText extends StatelessWidget {
|
||||
// 전체 텍스트
|
||||
final String text;
|
||||
// 하이라이트할 부분
|
||||
final String highlight;
|
||||
// 하이라이트 색상
|
||||
final Color highlightColor;
|
||||
// 텍스트 스타일
|
||||
final TextStyle? style;
|
||||
|
||||
const HighlightText({
|
||||
Key? key,
|
||||
required this.text,
|
||||
required this.highlight,
|
||||
this.highlightColor = Colors.blue,
|
||||
this.style,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (highlight.isEmpty) {
|
||||
// 하이라이트가 없으면 전체 텍스트 반환
|
||||
return Text(text, style: style);
|
||||
}
|
||||
final String lowerText = text.toLowerCase();
|
||||
final String lowerHighlight = highlight.toLowerCase();
|
||||
final int start = lowerText.indexOf(lowerHighlight);
|
||||
if (start < 0) {
|
||||
// 일치하는 부분이 없으면 전체 텍스트 반환
|
||||
return Text(text, style: style);
|
||||
}
|
||||
final int end = start + highlight.length;
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
style: style ?? DefaultTextStyle.of(context).style,
|
||||
children: [
|
||||
if (start > 0) TextSpan(text: text.substring(0, start)),
|
||||
TextSpan(
|
||||
text: text.substring(start, end),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: highlightColor,
|
||||
),
|
||||
),
|
||||
if (end < text.length) TextSpan(text: text.substring(end)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
27
lib/screens/common/custom_widgets/page_title.dart
Normal file
27
lib/screens/common/custom_widgets/page_title.dart
Normal file
@@ -0,0 +1,27 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
// 페이지 타이틀 위젯
|
||||
class PageTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? rightWidget;
|
||||
final double? width;
|
||||
|
||||
const PageTitle({Key? key, required this.title, this.rightWidget, this.width})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
margin: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title, style: AppThemeTailwind.headingStyle),
|
||||
if (rightWidget != null) rightWidget!,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
9
lib/screens/common/layout_components.dart
Normal file
9
lib/screens/common/layout_components.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
/// 메트로닉 스타일 공통 레이아웃 컴포넌트 barrel 파일
|
||||
/// 각 위젯은 SRP에 따라 별도 파일로 분리되어 있습니다.
|
||||
export 'metronic_page_container.dart';
|
||||
export 'metronic_card.dart';
|
||||
export 'metronic_stats_card.dart';
|
||||
export 'metronic_page_title.dart';
|
||||
export 'metronic_data_table.dart';
|
||||
export 'metronic_form_field.dart';
|
||||
export 'metronic_tab_container.dart';
|
||||
126
lib/screens/common/main_layout.dart
Normal file
126
lib/screens/common/main_layout.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
class MainLayout extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
final String currentRoute;
|
||||
final List<Widget>? actions;
|
||||
final bool showBackButton;
|
||||
final Widget? floatingActionButton;
|
||||
|
||||
const MainLayout({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
required this.currentRoute,
|
||||
this.actions,
|
||||
this.showBackButton = false,
|
||||
this.floatingActionButton,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// MetronicCloud 스타일: 상단부 플랫, 여백 넓게, 타이틀/경로/버튼 스타일링
|
||||
return Scaffold(
|
||||
backgroundColor: AppThemeTailwind.surface,
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 상단 앱바
|
||||
_buildAppBar(context),
|
||||
// 컨텐츠
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
floatingActionButton: floatingActionButton,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAppBar(BuildContext context) {
|
||||
// 상단 앱바: 경로 텍스트가 수직 중앙에 오도록 조정, 배경색/글자색 변경
|
||||
return Container(
|
||||
height: 88,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 40),
|
||||
decoration: BoxDecoration(
|
||||
color: AppThemeTailwind.surface, // 회색 배경
|
||||
border: const Border(
|
||||
bottom: BorderSide(color: Color(0xFFF3F6F9), width: 1),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center, // Row 내에서 수직 중앙 정렬
|
||||
children: [
|
||||
// 경로 및 타이틀 영역 (수직 중앙 정렬)
|
||||
Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center, // Column 내에서 수직 중앙 정렬
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 경로 텍스트 (폰트 사이즈 2배, 검은색 글자)
|
||||
Text(
|
||||
_getBreadcrumb(currentRoute),
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
color: AppThemeTailwind.dark,
|
||||
), // 검은색 글자
|
||||
),
|
||||
// 타이틀이 있을 때만 표시
|
||||
if (title.isNotEmpty)
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppThemeTailwind.dark,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Spacer(),
|
||||
if (actions != null)
|
||||
Row(
|
||||
children:
|
||||
actions!
|
||||
.map(
|
||||
(w) => Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: AppThemeTailwind.muted,
|
||||
width: 1,
|
||||
),
|
||||
color: const Color(0xFFF7F8FA),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: w,
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 현재 라우트에 따라 경로 문자열을 반환하는 함수
|
||||
String _getBreadcrumb(String route) {
|
||||
// 실제 라우트에 따라 경로를 한글로 변환 (예시)
|
||||
switch (route) {
|
||||
case '/':
|
||||
case '/home':
|
||||
return '홈 / 대시보드';
|
||||
case '/equipment':
|
||||
return '홈 / 장비 관리';
|
||||
case '/company':
|
||||
return '홈 / 회사 관리';
|
||||
case '/maintenance':
|
||||
return '홈 / 유지보수 관리';
|
||||
case '/item':
|
||||
return '홈 / 물품 관리';
|
||||
default:
|
||||
return '홈';
|
||||
}
|
||||
}
|
||||
}
|
||||
46
lib/screens/common/metronic_card.dart
Normal file
46
lib/screens/common/metronic_card.dart
Normal file
@@ -0,0 +1,46 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
/// 메트로닉 스타일 카드 위젯 (SRP 분리)
|
||||
class MetronicCard extends StatelessWidget {
|
||||
final String? title;
|
||||
final Widget child;
|
||||
final List<Widget>? actions;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final EdgeInsetsGeometry? margin;
|
||||
|
||||
const MetronicCard({
|
||||
Key? key,
|
||||
this.title,
|
||||
required this.child,
|
||||
this.actions,
|
||||
this.padding,
|
||||
this.margin,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: AppThemeTailwind.cardDecoration,
|
||||
margin: margin,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (title != null || actions != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (title != null)
|
||||
Text(title!, style: AppThemeTailwind.subheadingStyle),
|
||||
if (actions != null) Row(children: actions!),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(padding: padding ?? const EdgeInsets.all(16), child: child),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/screens/common/metronic_data_table.dart
Normal file
57
lib/screens/common/metronic_data_table.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
import 'package:superport/screens/common/metronic_card.dart';
|
||||
|
||||
/// 메트로닉 스타일 데이터 테이블 카드 위젯 (SRP 분리)
|
||||
class MetronicDataTable extends StatelessWidget {
|
||||
final List<DataColumn> columns;
|
||||
final List<DataRow> rows;
|
||||
final String? title;
|
||||
final bool isLoading;
|
||||
final String? emptyMessage;
|
||||
|
||||
const MetronicDataTable({
|
||||
Key? key,
|
||||
required this.columns,
|
||||
required this.rows,
|
||||
this.title,
|
||||
this.isLoading = false,
|
||||
this.emptyMessage,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MetronicCard(
|
||||
title: title,
|
||||
child:
|
||||
isLoading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: rows.isEmpty
|
||||
? Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Text(
|
||||
emptyMessage ?? '데이터가 없습니다.',
|
||||
style: AppThemeTailwind.bodyStyle,
|
||||
),
|
||||
),
|
||||
)
|
||||
: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.vertical,
|
||||
child: DataTable(
|
||||
columns: columns,
|
||||
rows: rows,
|
||||
headingRowColor: MaterialStateProperty.all(
|
||||
AppThemeTailwind.light,
|
||||
),
|
||||
dataRowMaxHeight: 60,
|
||||
columnSpacing: 24,
|
||||
horizontalMargin: 16,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
lib/screens/common/metronic_form_field.dart
Normal file
57
lib/screens/common/metronic_form_field.dart
Normal file
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
/// 메트로닉 스타일 폼 필드 래퍼 위젯 (SRP 분리)
|
||||
class MetronicFormField extends StatelessWidget {
|
||||
final String label;
|
||||
final Widget child;
|
||||
final bool isRequired;
|
||||
final String? helperText;
|
||||
|
||||
const MetronicFormField({
|
||||
Key? key,
|
||||
required this.label,
|
||||
required this.child,
|
||||
this.isRequired = false,
|
||||
this.helperText,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
color: AppThemeTailwind.dark,
|
||||
),
|
||||
),
|
||||
if (isRequired)
|
||||
const Text(
|
||||
' *',
|
||||
style: TextStyle(
|
||||
color: AppThemeTailwind.danger,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
child,
|
||||
if (helperText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Text(helperText!, style: AppThemeTailwind.smallText),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
lib/screens/common/metronic_page_container.dart
Normal file
35
lib/screens/common/metronic_page_container.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
/// 메트로닉 스타일 페이지 컨테이너 위젯 (SRP 분리)
|
||||
class MetronicPageContainer extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
final List<Widget>? actions;
|
||||
final bool showBackButton;
|
||||
|
||||
const MetronicPageContainer({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.actions,
|
||||
this.showBackButton = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(title),
|
||||
automaticallyImplyLeading: showBackButton,
|
||||
actions: actions,
|
||||
elevation: 0,
|
||||
),
|
||||
body: Container(
|
||||
color: AppThemeTailwind.surface,
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/screens/common/metronic_page_title.dart
Normal file
36
lib/screens/common/metronic_page_title.dart
Normal file
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
/// 메트로닉 스타일 페이지 타이틀 위젯 (SRP 분리)
|
||||
class MetronicPageTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final VoidCallback? onAddPressed;
|
||||
final String? addButtonLabel;
|
||||
|
||||
const MetronicPageTitle({
|
||||
Key? key,
|
||||
required this.title,
|
||||
this.onAddPressed,
|
||||
this.addButtonLabel,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(title, style: AppThemeTailwind.headingStyle),
|
||||
if (onAddPressed != null)
|
||||
ElevatedButton.icon(
|
||||
onPressed: onAddPressed,
|
||||
icon: const Icon(Icons.add),
|
||||
label: Text(addButtonLabel ?? '추가'),
|
||||
style: AppThemeTailwind.primaryButtonStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
lib/screens/common/metronic_stats_card.dart
Normal file
105
lib/screens/common/metronic_stats_card.dart
Normal file
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
/// 메트로닉 스타일 통계 카드 위젯 (SRP 분리)
|
||||
class MetronicStatsCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String value;
|
||||
final String? subtitle;
|
||||
final IconData? icon;
|
||||
final Color? iconBackgroundColor;
|
||||
final bool showTrend;
|
||||
final double? trendPercentage;
|
||||
final bool isPositiveTrend;
|
||||
|
||||
const MetronicStatsCard({
|
||||
Key? key,
|
||||
required this.title,
|
||||
required this.value,
|
||||
this.subtitle,
|
||||
this.icon,
|
||||
this.iconBackgroundColor,
|
||||
this.showTrend = false,
|
||||
this.trendPercentage,
|
||||
this.isPositiveTrend = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: AppThemeTailwind.cardDecoration,
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: AppThemeTailwind.bodyStyle.copyWith(
|
||||
color: AppThemeTailwind.muted,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (icon != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: iconBackgroundColor ?? AppThemeTailwind.light,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
color:
|
||||
iconBackgroundColor != null
|
||||
? Colors.white
|
||||
: AppThemeTailwind.primary,
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppThemeTailwind.dark,
|
||||
),
|
||||
),
|
||||
if (subtitle != null || showTrend) const SizedBox(height: 4),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!, style: AppThemeTailwind.smallText),
|
||||
if (showTrend && trendPercentage != null)
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isPositiveTrend ? Icons.arrow_upward : Icons.arrow_downward,
|
||||
color:
|
||||
isPositiveTrend
|
||||
? AppThemeTailwind.success
|
||||
: AppThemeTailwind.danger,
|
||||
size: 12,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${trendPercentage!.toStringAsFixed(1)}%',
|
||||
style: TextStyle(
|
||||
color:
|
||||
isPositiveTrend
|
||||
? AppThemeTailwind.success
|
||||
: AppThemeTailwind.danger,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
lib/screens/common/metronic_tab_container.dart
Normal file
48
lib/screens/common/metronic_tab_container.dart
Normal file
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:superport/screens/common/theme_tailwind.dart';
|
||||
|
||||
/// 메트로닉 스타일 탭 컨테이너 위젯 (SRP 분리)
|
||||
class MetronicTabContainer extends StatelessWidget {
|
||||
final List<String> tabs;
|
||||
final List<Widget> tabViews;
|
||||
final int initialIndex;
|
||||
|
||||
const MetronicTabContainer({
|
||||
Key? key,
|
||||
required this.tabs,
|
||||
required this.tabViews,
|
||||
this.initialIndex = 0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DefaultTabController(
|
||||
length: tabs.length,
|
||||
initialIndex: initialIndex,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFFE5E7EB), width: 1),
|
||||
),
|
||||
),
|
||||
child: TabBar(
|
||||
tabs: tabs.map((tab) => Tab(text: tab)).toList(),
|
||||
labelColor: AppThemeTailwind.primary,
|
||||
unselectedLabelColor: AppThemeTailwind.muted,
|
||||
labelStyle: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
indicatorColor: AppThemeTailwind.primary,
|
||||
indicatorWeight: 2,
|
||||
),
|
||||
),
|
||||
Expanded(child: TabBarView(children: tabViews)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
189
lib/screens/common/theme_tailwind.dart
Normal file
189
lib/screens/common/theme_tailwind.dart
Normal file
@@ -0,0 +1,189 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Metronic Admin 테일윈드 테마 (데모6 스타일)
|
||||
class AppThemeTailwind {
|
||||
// 메인 컬러 팔레트
|
||||
static const Color primary = Color(0xFF5867DD);
|
||||
static const Color secondary = Color(0xFF34BFA3);
|
||||
static const Color success = Color(0xFF1BC5BD);
|
||||
static const Color info = Color(0xFF8950FC);
|
||||
static const Color warning = Color(0xFFFFA800);
|
||||
static const Color danger = Color(0xFFF64E60);
|
||||
static const Color light = Color(0xFFF3F6F9);
|
||||
static const Color dark = Color(0xFF181C32);
|
||||
static const Color muted = Color(0xFFB5B5C3);
|
||||
|
||||
// 배경 컬러
|
||||
static const Color surface = Color(0xFFF7F8FA);
|
||||
static const Color cardBackground = Colors.white;
|
||||
|
||||
// 테마 데이터
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
primaryColor: primary,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: primary,
|
||||
secondary: secondary,
|
||||
background: surface,
|
||||
surface: cardBackground,
|
||||
error: danger,
|
||||
),
|
||||
scaffoldBackgroundColor: surface,
|
||||
fontFamily: 'Poppins',
|
||||
|
||||
// AppBar 테마
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: dark,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
color: dark,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
iconTheme: IconThemeData(color: dark),
|
||||
),
|
||||
|
||||
// 버튼 테마
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
),
|
||||
),
|
||||
|
||||
// 카드 테마
|
||||
cardTheme: CardTheme(
|
||||
color: Colors.white,
|
||||
elevation: 1,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(8)),
|
||||
margin: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
|
||||
// 입력 폼 테마
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: primary),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderSide: const BorderSide(color: danger),
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.never,
|
||||
),
|
||||
|
||||
// 데이터 테이블 테마
|
||||
dataTableTheme: const DataTableThemeData(
|
||||
headingRowColor: WidgetStatePropertyAll(light),
|
||||
dividerThickness: 1,
|
||||
columnSpacing: 24,
|
||||
headingTextStyle: TextStyle(
|
||||
color: dark,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14,
|
||||
),
|
||||
dataTextStyle: TextStyle(color: Color(0xFF6C7293), fontSize: 14),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 스타일 - 헤딩 및 텍스트
|
||||
static const TextStyle headingStyle = TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: dark,
|
||||
);
|
||||
|
||||
static const TextStyle subheadingStyle = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: dark,
|
||||
);
|
||||
|
||||
static const TextStyle bodyStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
color: Color(0xFF6C7293),
|
||||
);
|
||||
|
||||
// 굵은 본문 텍스트
|
||||
static const TextStyle bodyBoldStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: dark,
|
||||
);
|
||||
|
||||
static const TextStyle smallText = TextStyle(fontSize: 12, color: muted);
|
||||
|
||||
// 버튼 스타일
|
||||
static final ButtonStyle primaryButtonStyle = ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
);
|
||||
|
||||
// 라벨 스타일
|
||||
static const TextStyle formLabelStyle = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: dark,
|
||||
);
|
||||
|
||||
static final ButtonStyle secondaryButtonStyle = ElevatedButton.styleFrom(
|
||||
backgroundColor: secondary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
);
|
||||
|
||||
static final ButtonStyle outlineButtonStyle = OutlinedButton.styleFrom(
|
||||
foregroundColor: primary,
|
||||
side: const BorderSide(color: primary),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)),
|
||||
);
|
||||
|
||||
// 카드 장식
|
||||
static final BoxDecoration cardDecoration = BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withAlpha(13),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
// 기타 장식
|
||||
static final BoxDecoration containerDecoration = BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
);
|
||||
|
||||
static const EdgeInsets cardPadding = EdgeInsets.all(20);
|
||||
static const EdgeInsets listPadding = EdgeInsets.symmetric(
|
||||
vertical: 8,
|
||||
horizontal: 16,
|
||||
);
|
||||
}
|
||||
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