Files
superport/lib/screens/common/app_layout.dart
JiWoong Sul 731dcd816b
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled
refactor: Repository 패턴 적용 및 Clean Architecture 완성
## 주요 변경사항

### 🏗️ Architecture
- Repository 패턴 전면 도입 (인터페이스/구현체 분리)
- Domain Layer에 Repository 인터페이스 정의
- Data Layer에 Repository 구현체 배치
- UseCase 의존성을 Service에서 Repository로 전환

### 📦 Dependency Injection
- GetIt 기반 DI Container 재구성 (lib/injection_container.dart)
- Repository 인터페이스와 구현체 등록
- Service와 Repository 공존 (마이그레이션 기간)

### 🔄 Migration Status
완료:
- License 모듈 (6개 UseCase)
- Warehouse Location 모듈 (5개 UseCase)

진행중:
- Auth 모듈 (2/5 UseCase)
- Company 모듈 (1/6 UseCase)

대기:
- User 모듈 (7개 UseCase)
- Equipment 모듈 (4개 UseCase)

### 🎯 Controller 통합
- 중복 Controller 제거 (with_usecase 버전)
- 단일 Controller로 통합
- UseCase 패턴 직접 적용

### 🧹 코드 정리
- 임시 파일 제거 (test_*.md, task.md)
- Node.js 아티팩트 제거 (package.json)
- 불필요한 테스트 파일 정리

###  테스트 개선
- Real API 중심 테스트 구조
- Mock 제거, 실제 API 엔드포인트 사용
- 통합 테스트 프레임워크 강화

## 기술적 영향
- 의존성 역전 원칙 적용
- 레이어 간 결합도 감소
- 테스트 용이성 향상
- 확장성 및 유지보수성 개선

## 다음 단계
1. User/Equipment 모듈 Repository 마이그레이션
2. Service Layer 점진적 제거
3. 캐싱 전략 구현
4. 성능 최적화
2025-08-11 20:14:10 +09:00

1145 lines
38 KiB
Dart

import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';
import 'package:provider/provider.dart';
import 'package:superport/screens/common/theme_shadcn.dart';
import 'package:superport/screens/common/components/shadcn_components.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/services/auth_service.dart';
import 'package:superport/services/dashboard_service.dart';
import 'package:superport/services/lookup_service.dart';
import 'package:superport/utils/constants.dart';
import 'package:superport/data/models/auth/auth_user.dart';
/// ERP 시스템 최적화 메인 레이아웃
/// F-Pattern 레이아웃 적용 (1920x1080 최적화)
/// 상단 헤더 + 좌측 사이드바 + 메인 콘텐츠 구조
class AppLayout extends StatefulWidget {
final String initialRoute;
const AppLayout({Key? key, this.initialRoute = Routes.home})
: super(key: key);
@override
State<AppLayout> createState() => _AppLayoutState();
}
class _AppLayoutState extends State<AppLayout>
with TickerProviderStateMixin {
late String _currentRoute;
bool _sidebarCollapsed = false;
late AnimationController _sidebarAnimationController;
AuthUser? _currentUser;
late final AuthService _authService;
late final DashboardService _dashboardService;
late final LookupService _lookupService;
late Animation<double> _sidebarAnimation;
int _expiringLicenseCount = 0; // 7일 내 만료 예정 라이선스 수
// 레이아웃 상수 (1920x1080 최적화)
static const double _sidebarExpandedWidth = 260.0;
static const double _sidebarCollapsedWidth = 72.0;
static const double _headerHeight = 64.0;
static const double _maxContentWidth = 1440.0;
@override
void initState() {
super.initState();
_currentRoute = widget.initialRoute;
_setupAnimations();
_authService = GetIt.instance<AuthService>();
_dashboardService = GetIt.instance<DashboardService>();
_lookupService = GetIt.instance<LookupService>();
_loadCurrentUser();
_loadLicenseExpirySummary();
_initializeLookupData(); // Lookup 데이터 초기화
}
Future<void> _loadCurrentUser() async {
final user = await _authService.getCurrentUser();
if (mounted) {
setState(() {
_currentUser = user;
});
}
}
Future<void> _loadLicenseExpirySummary() async {
try {
print('[DEBUG] 라이선스 만료 정보 로드 시작...');
final result = await _dashboardService.getLicenseExpirySummary();
result.fold(
(failure) {
// 실패 시 0으로 유지
print('[ERROR] 라이선스 만료 정보 로드 실패: $failure');
},
(summary) {
print('[DEBUG] 라이선스 만료 정보 로드 성공!');
print('[DEBUG] 7일 내 만료: ${summary.expiring7Days ?? 0}');
print('[DEBUG] 30일 내 만료: ${summary.within30Days}');
print('[DEBUG] 60일 내 만료: ${summary.within60Days}');
print('[DEBUG] 90일 내 만료: ${summary.within90Days}');
print('[DEBUG] 이미 만료: ${summary.expired}');
if (mounted) {
setState(() {
// 30일 내 만료 수를 표시 (7일 내 만료가 포함됨)
// expiring_30_days는 30일 이내의 모든 라이선스를 포함
_expiringLicenseCount = summary.within30Days;
print('[DEBUG] 상태 업데이트 완료: $_expiringLicenseCount (30일 내 만료)');
});
}
},
);
} catch (e) {
print('[ERROR] 라이선스 만료 정보 로드 중 예외 발생: $e');
print('[ERROR] 스택 트레이스: ${StackTrace.current}');
}
}
/// Lookup 데이터 초기화 (앱 시작 시 한 번만 호출)
Future<void> _initializeLookupData() async {
try {
print('[DEBUG] Lookup 데이터 초기화 시작...');
// 캐시가 유효하지 않을 때만 로드
if (!_lookupService.isCacheValid) {
await _lookupService.loadAllLookups();
if (_lookupService.hasData) {
print('[DEBUG] Lookup 데이터 로드 성공!');
print('[DEBUG] - 장비 타입: ${_lookupService.equipmentTypes.length}');
print('[DEBUG] - 장비 상태: ${_lookupService.equipmentStatuses.length}');
print('[DEBUG] - 라이선스 타입: ${_lookupService.licenseTypes.length}');
print('[DEBUG] - 제조사: ${_lookupService.manufacturers.length}');
print('[DEBUG] - 사용자 역할: ${_lookupService.userRoles.length}');
print('[DEBUG] - 회사 상태: ${_lookupService.companyStatuses.length}');
} else {
print('[WARNING] Lookup 데이터가 비어있습니다.');
}
} else {
print('[DEBUG] Lookup 데이터 캐시 사용 (유효)');
}
if (_lookupService.error != null) {
print('[ERROR] Lookup 데이터 로드 실패: ${_lookupService.error}');
}
} catch (e) {
print('[ERROR] Lookup 데이터 초기화 중 예외 발생: $e');
}
}
void _setupAnimations() {
_sidebarAnimationController = AnimationController(
duration: const Duration(milliseconds: 250),
vsync: this,
);
_sidebarAnimation = Tween<double>(
begin: _sidebarExpandedWidth,
end: _sidebarCollapsedWidth
).animate(
CurvedAnimation(
parent: _sidebarAnimationController,
curve: Curves.easeInOutCubic,
),
);
}
@override
void dispose() {
_sidebarAnimationController.dispose();
super.dispose();
}
/// 현재 경로에 따라 적절한 컨텐츠 섹션을 반환
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 EquipmentList(currentRoute: route);
case Routes.company:
return const CompanyList();
case Routes.user:
return const UserList();
case Routes.license:
return const LicenseList();
case Routes.warehouseLocation:
return const WarehouseLocationList();
case '/test/api':
// Navigator를 사용하여 별도 화면으로 이동
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.pushNamed(context, '/test/api');
});
return const Center(child: CircularProgressIndicator());
default:
return const OverviewScreen();
}
}
/// 경로 변경 메서드
void _navigateTo(String route) {
setState(() {
_currentRoute = route;
});
// 라이선스 화면으로 이동할 때 만료 정보 새로고침
if (route == Routes.license) {
_loadLicenseExpirySummary();
}
}
/// 사이드바 토글
void _toggleSidebar() {
setState(() {
_sidebarCollapsed = !_sidebarCollapsed;
});
if (_sidebarCollapsed) {
_sidebarAnimationController.forward();
} else {
_sidebarAnimationController.reverse();
}
}
/// 현재 페이지 제목 가져오기
String _getPageTitle() {
switch (_currentRoute) {
case Routes.home:
return '대시보드';
case Routes.equipment:
case Routes.equipmentInList:
case Routes.equipmentOutList:
case Routes.equipmentRentList:
return '장비 관리';
case Routes.company:
return '회사 관리';
case Routes.license:
return '유지보수 관리';
case Routes.warehouseLocation:
return '입고지 관리';
case '/test/api':
return 'API 테스트';
default:
return '대시보드';
}
}
/// 브레드크럼 경로 가져오기
List<String> _getBreadcrumbs() {
switch (_currentRoute) {
case Routes.home:
return ['', '대시보드'];
case Routes.equipment:
return ['', '장비 관리', '전체'];
case Routes.equipmentInList:
return ['', '장비 관리', '입고'];
case Routes.equipmentOutList:
return ['', '장비 관리', '출고'];
case Routes.equipmentRentList:
return ['', '장비 관리', '대여'];
case Routes.company:
return ['', '회사 관리'];
case Routes.license:
return ['', '유지보수 관리'];
case Routes.warehouseLocation:
return ['', '입고지 관리'];
case '/test/api':
return ['', '개발자 도구', 'API 테스트'];
default:
return ['', '대시보드'];
}
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final isWideScreen = screenWidth >= 1920;
return Provider<AuthService>.value(
value: _authService,
child: Scaffold(
backgroundColor: ShadcnTheme.backgroundSecondary,
body: Column(
children: [
// F-Pattern: 1차 시선 - 상단 헤더
_buildTopHeader(),
// 메인 콘텐츠 영역
Expanded(
child: Row(
children: [
// 좌측 사이드바
AnimatedBuilder(
animation: _sidebarAnimation,
builder: (context, child) {
return Container(
width: _sidebarAnimation.value,
decoration: BoxDecoration(
color: ShadcnTheme.background,
border: Border(
right: BorderSide(
color: ShadcnTheme.border,
width: 1,
),
),
),
child: _buildSidebar(),
);
},
),
// 메인 콘텐츠 (최대 너비 제한)
Expanded(
child: Center(
child: Container(
constraints: BoxConstraints(
maxWidth: isWideScreen ? _maxContentWidth : double.infinity,
),
padding: EdgeInsets.all(
isWideScreen ? ShadcnTheme.spacing6 : ShadcnTheme.spacing4
),
child: Column(
children: [
// F-Pattern: 2차 시선 - 페이지 헤더 + 액션
_buildPageHeader(),
const SizedBox(height: ShadcnTheme.spacing4),
// F-Pattern: 주요 작업 영역
Expanded(
child: Container(
decoration: BoxDecoration(
color: ShadcnTheme.background,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(
color: ShadcnTheme.border,
width: 1,
),
boxShadow: ShadcnTheme.shadowSm,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg - 1),
child: _getContentForRoute(_currentRoute),
),
),
),
],
),
),
),
),
],
),
),
],
),
),
);
}
/// F-Pattern 1차 시선: 상단 헤더 (로고, 주요 메뉴, 알림, 프로필)
Widget _buildTopHeader() {
return Container(
height: _headerHeight,
decoration: BoxDecoration(
color: ShadcnTheme.background,
border: Border(
bottom: BorderSide(
color: ShadcnTheme.border,
width: 1,
),
),
boxShadow: ShadcnTheme.shadowXs,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing4),
child: Row(
children: [
// 왼쪽: 로고 + 사이드바 토글
Row(
children: [
// 사이드바 토글 버튼
SizedBox(
width: 40,
height: 40,
child: IconButton(
onPressed: _toggleSidebar,
icon: AnimatedRotation(
turns: _sidebarCollapsed ? 0 : 0.5,
duration: const Duration(milliseconds: 250),
child: Icon(
Icons.menu,
color: ShadcnTheme.foregroundSecondary,
size: 20,
),
),
tooltip: _sidebarCollapsed ? '사이드바 펼치기' : '사이드바 접기',
),
),
const SizedBox(width: ShadcnTheme.spacing3),
// 앱 로고 및 제목
Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
ShadcnTheme.primary,
ShadcnTheme.primaryDark,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Icon(
Icons.directions_boat,
size: 20,
color: ShadcnTheme.primaryForeground,
),
),
const SizedBox(width: ShadcnTheme.spacing3),
Text(
'supERPort',
style: ShadcnTheme.headingH5.copyWith(
fontWeight: FontWeight.w600,
),
),
],
),
],
),
const Spacer(),
// 오른쪽: 알림 + 프로필
Row(
children: [
// 검색
SizedBox(
width: 40,
height: 40,
child: IconButton(
onPressed: () {
// 전역 검색 기능
},
icon: Icon(
Icons.search,
color: ShadcnTheme.foregroundSecondary,
size: 20,
),
tooltip: '검색',
),
),
const SizedBox(width: ShadcnTheme.spacing2),
// 알림
SizedBox(
width: 40,
height: 40,
child: Stack(
children: [
IconButton(
onPressed: () {
// 알림 기능
},
icon: Icon(
Icons.notifications_outlined,
color: ShadcnTheme.foregroundSecondary,
size: 20,
),
tooltip: '알림',
),
Positioned(
right: 10,
top: 10,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: ShadcnTheme.error,
shape: BoxShape.circle,
border: Border.all(
color: ShadcnTheme.background,
width: 1.5,
),
),
),
),
],
),
),
const SizedBox(width: ShadcnTheme.spacing3),
const ShadcnSeparator(
direction: Axis.vertical,
thickness: 1,
),
const SizedBox(width: ShadcnTheme.spacing3),
// 프로필
InkWell(
onTap: () => _showProfileMenu(context),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusFull),
child: Padding(
padding: const EdgeInsets.all(ShadcnTheme.spacing1),
child: Row(
children: [
ShadcnAvatar(
initials: _currentUser?.name.substring(0, 1).toUpperCase() ?? 'U',
size: 32,
backgroundColor: ShadcnTheme.primaryLight,
textColor: ShadcnTheme.primary,
showBorder: false,
),
const SizedBox(width: ShadcnTheme.spacing2),
Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_currentUser?.name ?? '사용자',
style: ShadcnTheme.labelMedium,
),
Text(
_getUserRoleText(_currentUser?.role),
style: ShadcnTheme.caption.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
],
),
const SizedBox(width: ShadcnTheme.spacing2),
Icon(
Icons.expand_more,
size: 16,
color: ShadcnTheme.foregroundMuted,
),
],
),
),
),
],
),
],
),
),
);
}
/// 사용자 역할 텍스트 변환
String _getUserRoleText(String? role) {
switch (role) {
case 'admin':
return '관리자';
case 'manager':
return '매니저';
case 'member':
return '일반 사용자';
default:
return '사용자';
}
}
/// 사이드바 빌드
Widget _buildSidebar() {
return SidebarMenu(
currentRoute: _currentRoute,
onRouteChanged: _navigateTo,
collapsed: _sidebarCollapsed,
expiringLicenseCount: _expiringLicenseCount,
);
}
/// F-Pattern 2차 시선: 페이지 헤더 (제목 + 주요 액션 버튼)
Widget _buildPageHeader() {
final breadcrumbs = _getBreadcrumbs();
return Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
decoration: BoxDecoration(
color: ShadcnTheme.background,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
border: Border.all(
color: ShadcnTheme.border,
width: 1,
),
),
child: Row(
children: [
// 왼쪽: 페이지 제목 + 브레드크럼
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 페이지 제목
Text(
_getPageTitle(),
style: ShadcnTheme.headingH4,
),
const SizedBox(height: ShadcnTheme.spacing1),
// 브레드크럼
Row(
children: [
for (int i = 0; i < breadcrumbs.length; i++) ...[
if (i > 0) ...[
const SizedBox(width: ShadcnTheme.spacing1),
Icon(
Icons.chevron_right,
size: 14,
color: ShadcnTheme.foregroundSubtle,
),
const SizedBox(width: ShadcnTheme.spacing1),
],
Text(
breadcrumbs[i],
style: i == breadcrumbs.length - 1
? ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foreground,
fontWeight: FontWeight.w500,
)
: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
],
],
),
],
),
),
// 오른쪽: 페이지별 주요 액션 버튼들
if (_currentRoute != Routes.home) ...[
Row(
children: [
// 새로고침
ShadcnButton(
text: '',
icon: Icon(Icons.refresh, size: 18),
onPressed: () {
// 페이지 새로고침
setState(() {});
_loadLicenseExpirySummary(); // 라이선스 만료 정보도 새로고침
},
variant: ShadcnButtonVariant.ghost,
size: ShadcnButtonSize.small,
),
const SizedBox(width: ShadcnTheme.spacing2),
// 추가 버튼 (리스트 페이지에서만)
if (_isListPage()) ...[
ShadcnButton(
text: '추가',
icon: Icon(Icons.add, size: 18),
onPressed: () {
_handleAddAction();
},
variant: ShadcnButtonVariant.primary,
size: ShadcnButtonSize.small,
),
],
],
),
],
],
),
);
}
/// 리스트 페이지 여부 확인
bool _isListPage() {
return [
Routes.equipment,
Routes.equipmentInList,
Routes.equipmentOutList,
Routes.equipmentRentList,
Routes.company,
Routes.license,
Routes.warehouseLocation,
].contains(_currentRoute);
}
/// 추가 액션 처리
void _handleAddAction() {
String addRoute = '';
switch (_currentRoute) {
case Routes.equipment:
case Routes.equipmentInList:
addRoute = '/equipment/in';
break;
case Routes.equipmentOutList:
addRoute = '/equipment/out';
break;
case Routes.company:
addRoute = '/company/add';
break;
case Routes.license:
addRoute = '/license/add';
break;
case Routes.warehouseLocation:
addRoute = '/warehouse-location/add';
break;
}
if (addRoute.isNotEmpty) {
Navigator.pushNamed(context, addRoute).then((result) {
if (result == true) {
setState(() {});
}
});
}
}
/// 프로필 메뉴 표시
void _showProfileMenu(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: ShadcnTheme.background,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(ShadcnTheme.radiusXl),
),
),
builder: (context) => Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 핸들바
Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: ShadcnTheme.border,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(height: ShadcnTheme.spacing6),
// 프로필 정보
Row(
children: [
ShadcnAvatar(
initials: _currentUser?.name.substring(0, 1).toUpperCase() ?? 'U',
size: 56,
backgroundColor: ShadcnTheme.primaryLight,
textColor: ShadcnTheme.primary,
),
const SizedBox(width: ShadcnTheme.spacing4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_currentUser?.name ?? '사용자',
style: ShadcnTheme.headingH5,
),
const SizedBox(height: 2),
Text(
_currentUser?.email ?? '',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
const SizedBox(height: 4),
ShadcnBadge(
text: _getUserRoleText(_currentUser?.role),
variant: ShadcnBadgeVariant.primary,
size: ShadcnBadgeSize.small,
),
],
),
),
],
),
const SizedBox(height: ShadcnTheme.spacing6),
const ShadcnSeparator(),
const SizedBox(height: ShadcnTheme.spacing4),
// 메뉴 항목들
_buildProfileMenuItem(
icon: Icons.person_outline,
title: '프로필 설정',
onTap: () {
Navigator.pop(context);
// 프로필 설정 화면으로 이동
},
),
_buildProfileMenuItem(
icon: Icons.settings_outlined,
title: '환경 설정',
onTap: () {
Navigator.pop(context);
// 환경 설정 화면으로 이동
},
),
const SizedBox(height: ShadcnTheme.spacing4),
const ShadcnSeparator(),
const SizedBox(height: ShadcnTheme.spacing4),
// 로그아웃 버튼
ShadcnButton(
text: '로그아웃',
onPressed: () async {
// 로딩 다이얼로그 표시
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => Center(
child: Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing6),
decoration: BoxDecoration(
color: ShadcnTheme.background,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
),
child: const CircularProgressIndicator(),
),
),
);
try {
// AuthService를 사용하여 로그아웃
final authService = GetIt.instance<AuthService>();
await authService.logout();
// 로딩 다이얼로그와 현재 모달 닫기
if (context.mounted) {
Navigator.of(context).pop(); // 로딩 다이얼로그
Navigator.of(context).pop(); // 프로필 메뉴
// 로그인 화면으로 이동
Navigator.of(context).pushNamedAndRemoveUntil(
'/login',
(route) => false,
);
}
} catch (e) {
// 에러 처리
if (context.mounted) {
Navigator.of(context).pop(); // 로딩 다이얼로그 닫기
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('로그아웃 중 오류가 발생했습니다.'),
backgroundColor: ShadcnTheme.error,
),
);
}
}
},
variant: ShadcnButtonVariant.destructive,
fullWidth: true,
icon: Icon(Icons.logout, size: 18),
),
],
),
),
);
}
/// 프로필 메뉴 아이템
Widget _buildProfileMenuItem({
required IconData icon,
required String title,
required VoidCallback onTap,
}) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing3,
vertical: ShadcnTheme.spacing3,
),
child: Row(
children: [
Icon(
icon,
size: 20,
color: ShadcnTheme.foregroundSecondary,
),
const SizedBox(width: ShadcnTheme.spacing3),
Text(
title,
style: ShadcnTheme.bodyMedium,
),
],
),
),
);
}
}
/// 재설계된 사이드바 메뉴 (접기/펼치기 지원)
class SidebarMenu extends StatelessWidget {
final String currentRoute;
final Function(String) onRouteChanged;
final bool collapsed;
final int expiringLicenseCount;
const SidebarMenu({
Key? key,
required this.currentRoute,
required this.onRouteChanged,
required this.collapsed,
required this.expiringLicenseCount,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.all(
collapsed ? ShadcnTheme.spacing2 : ShadcnTheme.spacing3
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!collapsed) ...[
Padding(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing3,
vertical: ShadcnTheme.spacing2,
),
child: Text(
'메인 메뉴',
style: ShadcnTheme.caption.copyWith(
color: ShadcnTheme.foregroundMuted,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: ShadcnTheme.spacing1),
],
_buildMenuItem(
icon: Icons.dashboard_outlined,
title: '대시보드',
route: Routes.home,
isActive: currentRoute == Routes.home,
badge: null,
),
_buildMenuItem(
icon: Icons.inventory_2_outlined,
title: '장비 관리',
route: Routes.equipment,
isActive: [
Routes.equipment,
Routes.equipmentInList,
Routes.equipmentOutList,
Routes.equipmentRentList,
].contains(currentRoute),
badge: null,
),
_buildMenuItem(
icon: Icons.warehouse_outlined,
title: '입고지 관리',
route: Routes.warehouseLocation,
isActive: currentRoute == Routes.warehouseLocation,
badge: null,
),
_buildMenuItem(
icon: Icons.business_outlined,
title: '회사 관리',
route: Routes.company,
isActive: currentRoute == Routes.company,
badge: null,
),
_buildMenuItem(
icon: Icons.support_outlined,
title: '유지보수 관리',
route: Routes.license,
isActive: currentRoute == Routes.license,
badge: expiringLicenseCount > 0 ? expiringLicenseCount.toString() : null,
),
if (!collapsed) ...[
const SizedBox(height: ShadcnTheme.spacing4),
const ShadcnSeparator(),
const SizedBox(height: ShadcnTheme.spacing4),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing3,
vertical: ShadcnTheme.spacing2,
),
child: Text(
'개발자 도구',
style: ShadcnTheme.caption.copyWith(
color: ShadcnTheme.foregroundMuted,
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: ShadcnTheme.spacing1),
],
_buildMenuItem(
icon: Icons.bug_report_outlined,
title: 'API 테스트',
route: '/test/api',
isActive: currentRoute == '/test/api',
badge: null,
),
],
),
),
),
// 하단 버전 정보
if (!collapsed) ...[
const ShadcnSeparator(),
Container(
padding: const EdgeInsets.all(ShadcnTheme.spacing3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'supERPort v1.0.0',
style: ShadcnTheme.caption.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
const SizedBox(height: 2),
Text(
'© 2025 Superport',
style: ShadcnTheme.caption.copyWith(
color: ShadcnTheme.foregroundSubtle,
),
),
],
),
),
],
],
);
}
Widget _buildMenuItem({
required IconData icon,
required String title,
required String route,
required bool isActive,
String? badge,
}) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(bottom: ShadcnTheme.spacing1),
child: InkWell(
onTap: () => onRouteChanged(route),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
child: Container(
padding: EdgeInsets.symmetric(
horizontal: collapsed ? ShadcnTheme.spacing3 : ShadcnTheme.spacing3,
vertical: ShadcnTheme.spacing2 + 2,
),
decoration: BoxDecoration(
color: isActive
? ShadcnTheme.primaryLight
: Colors.transparent,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Row(
children: [
Icon(
isActive ? _getFilledIcon(icon) : icon,
size: 20,
color: isActive
? ShadcnTheme.primary
: ShadcnTheme.foregroundSecondary,
),
if (!collapsed) ...[
const SizedBox(width: ShadcnTheme.spacing3),
Expanded(
child: Text(
title,
style: ShadcnTheme.bodyMedium.copyWith(
color: isActive
? ShadcnTheme.primary
: ShadcnTheme.foreground,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
),
),
),
if (badge != null) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(10),
),
child: Text(
badge,
style: ShadcnTheme.caption.copyWith(
color: Colors.white,
fontWeight: FontWeight.w600,
),
),
),
],
],
if (collapsed && badge != null) ...[
Positioned(
right: 0,
top: 0,
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
),
),
),
],
],
),
),
),
);
}
/// 활성화 상태일 때 채워진 아이콘 반환
IconData _getFilledIcon(IconData outlinedIcon) {
switch (outlinedIcon) {
case Icons.dashboard_outlined:
return Icons.dashboard;
case Icons.inventory_2_outlined:
return Icons.inventory_2;
case Icons.warehouse_outlined:
return Icons.warehouse;
case Icons.business_outlined:
return Icons.business;
case Icons.support_outlined:
return Icons.support;
case Icons.bug_report_outlined:
return Icons.bug_report;
default:
return outlinedIcon;
}
}
}