## 주요 변경사항 ### 🏗️ 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. 성능 최적화
1145 lines
38 KiB
Dart
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;
|
|
}
|
|
}
|
|
} |