프로젝트 최초 커밋

This commit is contained in:
JiWoong Sul
2025-07-02 17:45:44 +09:00
commit e346f83c97
235 changed files with 23139 additions and 0 deletions

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

View 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';

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

View File

@@ -0,0 +1,18 @@
// 카테고리 데이터 (예시)
final Map<String, Map<String, List<String>>> categoryData = {
'컴퓨터': {
'데스크탑': ['사무용', '게이밍', '워크스테이션'],
'노트북': ['사무용', '게이밍', '울트라북'],
'태블릿': ['안드로이드', 'iOS', '윈도우'],
},
'네트워크': {
'라우터': ['가정용', '기업용', '산업용'],
'스위치': ['관리형', '비관리형'],
'액세스 포인트': ['실내용', '실외용'],
},
'주변기기': {
'모니터': ['LCD', 'LED', 'OLED'],
'키보드': ['유선', '무선', '기계식'],
'마우스': ['유선', '무선', '트랙볼'],
},
};

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

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

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

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

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

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

View 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';

View 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 '';
}
}
}

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

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

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

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

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

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

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

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

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

View 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=${widget.label}, 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]),
),
);
},
),
),
),
),
],
);
}
}

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

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

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

View 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(),
),
);
}
}