diff --git a/lib/screens/common/app_layout_redesign.dart b/lib/screens/common/app_layout_redesign.dart index 86e2393..b5c262c 100644 --- a/lib/screens/common/app_layout_redesign.dart +++ b/lib/screens/common/app_layout_redesign.dart @@ -12,7 +12,8 @@ import 'package:superport/services/auth_service.dart'; import 'package:superport/utils/constants.dart'; import 'package:superport/data/models/auth/auth_user.dart'; -/// Microsoft Dynamics 365 스타일의 메인 레이아웃 +/// ERP 시스템 최적화 메인 레이아웃 +/// F-Pattern 레이아웃 적용 (1920x1080 최적화) /// 상단 헤더 + 좌측 사이드바 + 메인 콘텐츠 구조 class AppLayoutRedesign extends StatefulWidget { final String initialRoute; @@ -32,6 +33,12 @@ class _AppLayoutRedesignState extends State AuthUser? _currentUser; late final AuthService _authService; late Animation _sidebarAnimation; + + // 레이아웃 상수 (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() { @@ -53,13 +60,16 @@ class _AppLayoutRedesignState extends State void _setupAnimations() { _sidebarAnimationController = AnimationController( - duration: const Duration(milliseconds: 300), + duration: const Duration(milliseconds: 250), vsync: this, ); - _sidebarAnimation = Tween(begin: 280.0, end: 72.0).animate( + _sidebarAnimation = Tween( + begin: _sidebarExpandedWidth, + end: _sidebarCollapsedWidth + ).animate( CurvedAnimation( parent: _sidebarAnimationController, - curve: Curves.easeInOut, + curve: Curves.easeInOutCubic, ), ); } @@ -170,11 +180,14 @@ class _AppLayoutRedesignState extends State @override Widget build(BuildContext context) { + final screenWidth = MediaQuery.of(context).size.width; + final isWideScreen = screenWidth >= 1920; + return Scaffold( - backgroundColor: ShadcnTheme.muted, + backgroundColor: ShadcnTheme.backgroundSecondary, body: Column( children: [ - // 상단 헤더 + // F-Pattern: 1차 시선 - 상단 헤더 _buildTopHeader(), // 메인 콘텐츠 영역 @@ -185,39 +198,254 @@ class _AppLayoutRedesignState extends State AnimatedBuilder( animation: _sidebarAnimation, builder: (context, child) { - return SizedBox( + return Container( width: _sidebarAnimation.value, + decoration: BoxDecoration( + color: ShadcnTheme.background, + border: Border( + right: BorderSide( + color: ShadcnTheme.border, + width: 1, + ), + ), + ), child: _buildSidebar(), ); }, ), - // 메인 콘텐츠 + // 메인 콘텐츠 (최대 너비 제한) Expanded( - child: Container( - margin: const EdgeInsets.all(ShadcnTheme.spacing4), - decoration: BoxDecoration( - color: ShadcnTheme.background, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), - border: Border.all(color: Colors.black), - boxShadow: ShadcnTheme.cardShadow, - ), - child: Column( - children: [ - // 페이지 헤더 - _buildPageHeader(), - - // 메인 콘텐츠 - Expanded( - child: ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(ShadcnTheme.radiusLg), - bottomRight: Radius.circular( - ShadcnTheme.radiusLg, + 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), ), ), - 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, ), ], ), @@ -225,197 +453,172 @@ class _AppLayoutRedesignState extends State ), ], ), - ), - ], - ), - ); - } - - /// 상단 헤더 빌드 - Widget _buildTopHeader() { - return Container( - height: 64, - decoration: BoxDecoration( - color: ShadcnTheme.background, - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.05), - blurRadius: 10, - offset: const Offset(0, 2), - ), - ], - ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: ShadcnTheme.spacing4), - child: Row( - children: [ - // 사이드바 토글 버튼 - IconButton( - onPressed: _toggleSidebar, - icon: Icon( - _sidebarCollapsed ? Icons.menu : Icons.menu_open, - color: ShadcnTheme.foreground, - ), - tooltip: _sidebarCollapsed ? '사이드바 펼치기' : '사이드바 접기', - ), - - const SizedBox(width: ShadcnTheme.spacing4), - - // 앱 로고 및 제목 - Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing2), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ShadcnTheme.gradient1, ShadcnTheme.gradient2], - ), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - ), - child: Icon( - Icons.directions_boat, - size: 24, - color: ShadcnTheme.primaryForeground, - ), - ), - - const SizedBox(width: ShadcnTheme.spacing3), - - Text('supERPort', style: ShadcnTheme.headingH4), - - const Spacer(), - - // 상단 액션 버튼들 - _buildTopActions(), ], ), ), ); } - /// 상단 액션 버튼들 - Widget _buildTopActions() { - return Row( - children: [ - // 알림 버튼 - Container( - decoration: BoxDecoration( - color: ShadcnTheme.muted, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - ), - child: IconButton( - onPressed: () { - // 알림 기능 - }, - icon: Stack( - children: [ - Icon( - Icons.notifications_outlined, - color: ShadcnTheme.foreground, - ), - Positioned( - right: 0, - top: 0, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: ShadcnTheme.destructive, - shape: BoxShape.circle, - ), - ), - ), - ], - ), - tooltip: '알림', - ), - ), - - const SizedBox(width: ShadcnTheme.spacing2), - - // 설정 버튼 - Container( - decoration: BoxDecoration( - color: ShadcnTheme.muted, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - ), - child: IconButton( - onPressed: () { - // 설정 기능 - }, - icon: Icon(Icons.settings_outlined, color: ShadcnTheme.foreground), - tooltip: '설정', - ), - ), - - const SizedBox(width: ShadcnTheme.spacing4), - - // 프로필 아바타 - GestureDetector( - onTap: () { - _showProfileMenu(context); - }, - child: ShadcnAvatar( - initials: _currentUser != null ? _currentUser!.name.substring(0, 1).toUpperCase() : 'U', - size: 36, - ), - ), - ], - ); + /// 사용자 역할 텍스트 변환 + String _getUserRoleText(String? role) { + switch (role) { + case 'admin': + return '관리자'; + case 'manager': + return '매니저'; + case 'member': + return '일반 사용자'; + default: + return '사용자'; + } } /// 사이드바 빌드 Widget _buildSidebar() { - return Container( - decoration: BoxDecoration( - color: ShadcnTheme.background, - ), - child: SidebarMenuRedesign( - currentRoute: _currentRoute, - onRouteChanged: _navigateTo, - collapsed: _sidebarCollapsed, - ), + return SidebarMenuRedesign( + currentRoute: _currentRoute, + onRouteChanged: _navigateTo, + collapsed: _sidebarCollapsed, ); } - /// 페이지 헤더 빌드 + /// F-Pattern 2차 시선: 페이지 헤더 (제목 + 주요 액션 버튼) Widget _buildPageHeader() { final breadcrumbs = _getBreadcrumbs(); return Container( padding: const EdgeInsets.all(ShadcnTheme.spacing6), decoration: BoxDecoration( - border: Border(bottom: BorderSide(color: Colors.black)), + color: ShadcnTheme.background, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), + border: Border.all( + color: ShadcnTheme.border, + width: 1, + ), ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - // 브레드크럼 - Row( - children: [ - for (int i = 0; i < breadcrumbs.length; i++) ...[ - if (i > 0) ...[ - const SizedBox(width: ShadcnTheme.spacing2), - Icon( - Icons.chevron_right, - size: 16, - color: ShadcnTheme.mutedForeground, - ), - const SizedBox(width: ShadcnTheme.spacing2), - ], + // 왼쪽: 페이지 제목 + 브레드크럼 + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 페이지 제목 Text( - breadcrumbs[i], - style: - i == breadcrumbs.length - 1 - ? ShadcnTheme.bodyMedium - : ShadcnTheme.bodyMuted, + _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(() {}); + }, + 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( @@ -426,88 +629,172 @@ class _AppLayoutRedesignState extends State top: Radius.circular(ShadcnTheme.radiusXl), ), ), - builder: - (context) => Container( - padding: const EdgeInsets.all(ShadcnTheme.spacing6), - child: Column( - mainAxisSize: MainAxisSize.min, + 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: [ - // 프로필 정보 - Row( - children: [ - ShadcnAvatar( - initials: _currentUser != null ? _currentUser!.name.substring(0, 1).toUpperCase() : 'U', - size: 48, - ), - const SizedBox(width: ShadcnTheme.spacing4), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _currentUser?.name ?? '사용자', - style: ShadcnTheme.headingH4, - ), - Text( - _currentUser?.email ?? '', - style: ShadcnTheme.bodyMuted, - ), - ], - ), - ], + ShadcnAvatar( + initials: _currentUser?.name.substring(0, 1).toUpperCase() ?? 'U', + size: 56, + backgroundColor: ShadcnTheme.primaryLight, + textColor: ShadcnTheme.primary, ), - - const SizedBox(height: ShadcnTheme.spacing6), - const ShadcnSeparator(), - const SizedBox(height: ShadcnTheme.spacing4), - - // 로그아웃 버튼 - ShadcnButton( - text: '로그아웃', - onPressed: () async { - // 로딩 다이얼로그 표시 - showDialog( - context: context, - barrierDismissible: false, - builder: (context) => Center( - child: CircularProgressIndicator(), + const SizedBox(width: ShadcnTheme.spacing4), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _currentUser?.name ?? '사용자', + style: ShadcnTheme.headingH5, ), - ); - - try { - // AuthService를 사용하여 로그아웃 - final authService = GetIt.instance(); - 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: Colors.red, - ), - ); - } - } - }, - variant: ShadcnButtonVariant.destructive, - fullWidth: true, - icon: Icon(Icons.logout), + 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(); + 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, + ), + ], + ), + ), ); } } @@ -531,20 +818,40 @@ class SidebarMenuRedesign extends StatelessWidget { children: [ Expanded( child: SingleChildScrollView( - padding: const EdgeInsets.all(ShadcnTheme.spacing4), + 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, + icon: Icons.dashboard_outlined, title: '대시보드', route: Routes.home, isActive: currentRoute == Routes.home, + badge: null, ), - const SizedBox(height: ShadcnTheme.spacing2), - _buildMenuItem( - icon: Icons.inventory, + icon: Icons.inventory_2_outlined, title: '장비 관리', route: Routes.equipment, isActive: [ @@ -553,50 +860,92 @@ class SidebarMenuRedesign extends StatelessWidget { Routes.equipmentOutList, Routes.equipmentRentList, ].contains(currentRoute), + badge: null, ), - const SizedBox(height: ShadcnTheme.spacing2), - _buildMenuItem( - icon: Icons.location_on, + icon: Icons.warehouse_outlined, title: '입고지 관리', route: Routes.warehouseLocation, isActive: currentRoute == Routes.warehouseLocation, + badge: null, ), - const SizedBox(height: ShadcnTheme.spacing2), - _buildMenuItem( - icon: Icons.business, + icon: Icons.business_outlined, title: '회사 관리', route: Routes.company, isActive: currentRoute == Routes.company, + badge: null, ), - const SizedBox(height: ShadcnTheme.spacing2), - _buildMenuItem( - icon: Icons.vpn_key, + icon: Icons.support_outlined, title: '유지보수 관리', route: Routes.license, isActive: currentRoute == Routes.license, + badge: '3', // 만료 임박 라이선스 수 ), - const SizedBox(height: ShadcnTheme.spacing4), - const Divider(), - const SizedBox(height: ShadcnTheme.spacing4), + 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, + 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, + ), + ), + ], + ), + ), + ], ], ); } @@ -606,47 +955,105 @@ class SidebarMenuRedesign extends StatelessWidget { required String title, required String route, required bool isActive, + String? badge, }) { - return GestureDetector( - onTap: () => onRouteChanged(route), - child: Container( - width: double.infinity, - padding: EdgeInsets.symmetric( - horizontal: collapsed ? ShadcnTheme.spacing2 : ShadcnTheme.spacing4, - vertical: ShadcnTheme.spacing3, - ), - decoration: BoxDecoration( - color: isActive ? ShadcnTheme.primary : Colors.transparent, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - ), - child: Row( - children: [ - Icon( - icon, - size: 20, - color: - isActive - ? ShadcnTheme.primaryForeground - : ShadcnTheme.foreground, - ), - if (!collapsed) ...[ - const SizedBox(width: ShadcnTheme.spacing3), - Expanded( - child: Text( - title, - style: ShadcnTheme.bodyMedium.copyWith( - color: - isActive - ? ShadcnTheme.primaryForeground - : ShadcnTheme.foreground, - fontWeight: isActive ? FontWeight.w600 : FontWeight.w400, + 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: ShadcnTheme.error, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + badge, + style: ShadcnTheme.caption.copyWith( + color: ShadcnTheme.errorForeground, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ], + if (collapsed && badge != null) ...[ + Positioned( + right: 0, + top: 0, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: ShadcnTheme.error, + 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; + } + } +} \ No newline at end of file diff --git a/lib/screens/common/components/shadcn_components.dart b/lib/screens/common/components/shadcn_components.dart index 977c529..1019633 100644 --- a/lib/screens/common/components/shadcn_components.dart +++ b/lib/screens/common/components/shadcn_components.dart @@ -1,16 +1,18 @@ import 'package:flutter/material.dart'; import 'package:superport/screens/common/theme_shadcn.dart'; -/// shadcn/ui 스타일 기본 컴포넌트들 +/// ERP 시스템에 최적화된 UI 컴포넌트들 -// 카드 컴포넌트 -class ShadcnCard extends StatelessWidget { +// ============= 카드 컴포넌트 ============= +class ShadcnCard extends StatefulWidget { final Widget child; final EdgeInsetsGeometry? padding; final EdgeInsetsGeometry? margin; final double? width; final double? height; final VoidCallback? onTap; + final bool hoverable; + final bool elevated; const ShadcnCard({ Key? key, @@ -20,34 +22,62 @@ class ShadcnCard extends StatelessWidget { this.width, this.height, this.onTap, + this.hoverable = true, + this.elevated = false, }) : super(key: key); + @override + State createState() => _ShadcnCardState(); +} + +class _ShadcnCardState extends State { + bool _isHovered = false; + @override Widget build(BuildContext context) { - final cardContent = Container( - width: width, - height: height, - padding: padding ?? const EdgeInsets.all(ShadcnTheme.spacing6), - margin: margin, - decoration: BoxDecoration( - color: ShadcnTheme.card, - borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), - border: Border.all(color: Colors.black), - boxShadow: ShadcnTheme.cardShadow, + final cardContent = MouseRegion( + onEnter: widget.hoverable ? (_) => setState(() => _isHovered = true) : null, + onExit: widget.hoverable ? (_) => setState(() => _isHovered = false) : null, + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: widget.width, + height: widget.height, + padding: widget.padding ?? const EdgeInsets.all(ShadcnTheme.spacing6), + margin: widget.margin, + decoration: BoxDecoration( + color: _isHovered && widget.hoverable + ? ShadcnTheme.cardHover + : ShadcnTheme.card, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg), + border: Border.all( + color: _isHovered && widget.hoverable + ? ShadcnTheme.borderStrong + : ShadcnTheme.border, + width: 1, + ), + boxShadow: widget.elevated + ? ShadcnTheme.shadowLg + : _isHovered && widget.hoverable + ? ShadcnTheme.shadowMd + : ShadcnTheme.shadowSm, + ), + child: widget.child, ), - child: child, ); - if (onTap != null) { - return GestureDetector(onTap: onTap, child: cardContent); + if (widget.onTap != null) { + return GestureDetector( + onTap: widget.onTap, + child: cardContent, + ); } return cardContent; } } -// 버튼 컴포넌트 -class ShadcnButton extends StatelessWidget { +// ============= 버튼 컴포넌트 ============= +class ShadcnButton extends StatefulWidget { final String text; final VoidCallback? onPressed; final ShadcnButtonVariant variant; @@ -71,81 +101,133 @@ class ShadcnButton extends StatelessWidget { this.textColor, }) : super(key: key); + @override + State createState() => _ShadcnButtonState(); +} + +class _ShadcnButtonState extends State { + bool _isHovered = false; + bool _isPressed = false; + @override Widget build(BuildContext context) { final ButtonStyle style = _getButtonStyle(); final EdgeInsetsGeometry padding = _getPadding(); Widget buttonChild = Row( - mainAxisSize: fullWidth ? MainAxisSize.max : MainAxisSize.min, + mainAxisSize: widget.fullWidth ? MainAxisSize.max : MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ - if (loading) + if (widget.loading) SizedBox( - width: 16, - height: 16, + width: _getIconSize(), + height: _getIconSize(), child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - textColor ?? _getDefaultTextColor(), + widget.textColor ?? _getDefaultTextColor(), ), ), ) - else if (icon != null) - icon!, - if ((loading || icon != null) && text.isNotEmpty) + else if (widget.icon != null) + widget.icon!, + if ((widget.loading || widget.icon != null) && widget.text.isNotEmpty) const SizedBox(width: ShadcnTheme.spacing2), - if (text.isNotEmpty) Text(text, style: _getTextStyle()), + if (widget.text.isNotEmpty) + Text(widget.text, style: _getTextStyle()), ], ); - if (variant == ShadcnButtonVariant.primary) { - return SizedBox( - width: fullWidth ? double.infinity : null, - child: ElevatedButton( - onPressed: loading ? null : onPressed, - style: style.copyWith(padding: WidgetStateProperty.all(padding)), - child: buttonChild, - ), + Widget button; + if (widget.variant == ShadcnButtonVariant.primary || + widget.variant == ShadcnButtonVariant.destructive) { + button = ElevatedButton( + onPressed: widget.loading ? null : widget.onPressed, + style: style.copyWith(padding: WidgetStateProperty.all(padding)), + child: buttonChild, ); - } else if (variant == ShadcnButtonVariant.secondary) { - return SizedBox( - width: fullWidth ? double.infinity : null, - child: OutlinedButton( - onPressed: loading ? null : onPressed, - style: style.copyWith(padding: WidgetStateProperty.all(padding)), - child: buttonChild, - ), + } else if (widget.variant == ShadcnButtonVariant.secondary) { + button = OutlinedButton( + onPressed: widget.loading ? null : widget.onPressed, + style: style.copyWith(padding: WidgetStateProperty.all(padding)), + child: buttonChild, ); } else { - return SizedBox( - width: fullWidth ? double.infinity : null, - child: TextButton( - onPressed: loading ? null : onPressed, - style: style.copyWith(padding: WidgetStateProperty.all(padding)), - child: buttonChild, - ), + button = TextButton( + onPressed: widget.loading ? null : widget.onPressed, + style: style.copyWith(padding: WidgetStateProperty.all(padding)), + child: buttonChild, ); } + + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: GestureDetector( + onTapDown: (_) => setState(() => _isPressed = true), + onTapUp: (_) => setState(() => _isPressed = false), + onTapCancel: () => setState(() => _isPressed = false), + child: AnimatedScale( + scale: _isPressed ? 0.98 : 1.0, + duration: const Duration(milliseconds: 100), + child: SizedBox( + width: widget.fullWidth ? double.infinity : null, + height: _getHeight(), + child: button, + ), + ), + ), + ); + } + + double _getHeight() { + switch (widget.size) { + case ShadcnButtonSize.small: + return 32; + case ShadcnButtonSize.medium: + return 40; + case ShadcnButtonSize.large: + return 48; + } + } + + double _getIconSize() { + switch (widget.size) { + case ShadcnButtonSize.small: + return 14; + case ShadcnButtonSize.medium: + return 16; + case ShadcnButtonSize.large: + return 20; + } } ButtonStyle _getButtonStyle() { - switch (variant) { + switch (widget.variant) { case ShadcnButtonVariant.primary: return ElevatedButton.styleFrom( - backgroundColor: backgroundColor ?? ShadcnTheme.primary, - foregroundColor: textColor ?? ShadcnTheme.primaryForeground, + backgroundColor: widget.backgroundColor ?? + (_isHovered ? ShadcnTheme.primaryDark : ShadcnTheme.primary), + foregroundColor: widget.textColor ?? ShadcnTheme.primaryForeground, elevation: 0, shadowColor: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), ), + ).copyWith( + overlayColor: WidgetStateProperty.all( + ShadcnTheme.primaryDark.withValues(alpha: 0.1), + ), ); case ShadcnButtonVariant.secondary: return OutlinedButton.styleFrom( - backgroundColor: backgroundColor ?? ShadcnTheme.secondary, - foregroundColor: textColor ?? ShadcnTheme.secondaryForeground, - side: const BorderSide(color: Colors.black), + backgroundColor: widget.backgroundColor ?? + (_isHovered ? ShadcnTheme.backgroundSecondary : Colors.transparent), + foregroundColor: widget.textColor ?? ShadcnTheme.foreground, + side: BorderSide( + color: _isHovered ? ShadcnTheme.borderStrong : ShadcnTheme.border, + width: 1, + ), elevation: 0, shadowColor: Colors.transparent, shape: RoundedRectangleBorder( @@ -154,8 +236,9 @@ class ShadcnButton extends StatelessWidget { ); case ShadcnButtonVariant.destructive: return ElevatedButton.styleFrom( - backgroundColor: backgroundColor ?? ShadcnTheme.destructive, - foregroundColor: textColor ?? ShadcnTheme.destructiveForeground, + backgroundColor: widget.backgroundColor ?? + (_isHovered ? Color(0xFFB91C1C) : ShadcnTheme.error), + foregroundColor: widget.textColor ?? ShadcnTheme.errorForeground, elevation: 0, shadowColor: Colors.transparent, shape: RoundedRectangleBorder( @@ -164,8 +247,9 @@ class ShadcnButton extends StatelessWidget { ); case ShadcnButtonVariant.ghost: return TextButton.styleFrom( - backgroundColor: backgroundColor ?? Colors.transparent, - foregroundColor: textColor ?? ShadcnTheme.foreground, + backgroundColor: widget.backgroundColor ?? + (_isHovered ? ShadcnTheme.backgroundSecondary : Colors.transparent), + foregroundColor: widget.textColor ?? ShadcnTheme.foreground, elevation: 0, shadowColor: Colors.transparent, shape: RoundedRectangleBorder( @@ -176,28 +260,28 @@ class ShadcnButton extends StatelessWidget { } EdgeInsetsGeometry _getPadding() { - switch (size) { + switch (widget.size) { case ShadcnButtonSize.small: return const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing3, - vertical: 6, + vertical: 0, ); case ShadcnButtonSize.medium: return const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing4, - vertical: 10, + vertical: 0, ); case ShadcnButtonSize.large: return const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing8, - vertical: ShadcnTheme.spacing3, + horizontal: ShadcnTheme.spacing6, + vertical: 0, ); } } TextStyle _getTextStyle() { TextStyle baseStyle; - switch (size) { + switch (widget.size) { case ShadcnButtonSize.small: baseStyle = ShadcnTheme.labelSmall; break; @@ -208,17 +292,19 @@ class ShadcnButton extends StatelessWidget { baseStyle = ShadcnTheme.labelLarge; break; } - return textColor != null ? baseStyle.copyWith(color: textColor) : baseStyle; + return widget.textColor != null + ? baseStyle.copyWith(color: widget.textColor, fontWeight: FontWeight.w500) + : baseStyle.copyWith(fontWeight: FontWeight.w500); } Color _getDefaultTextColor() { - switch (variant) { + switch (widget.variant) { case ShadcnButtonVariant.primary: return ShadcnTheme.primaryForeground; case ShadcnButtonVariant.secondary: - return ShadcnTheme.secondaryForeground; + return ShadcnTheme.foreground; case ShadcnButtonVariant.destructive: - return ShadcnTheme.destructiveForeground; + return ShadcnTheme.errorForeground; case ShadcnButtonVariant.ghost: return ShadcnTheme.foreground; } @@ -231,11 +317,12 @@ enum ShadcnButtonVariant { primary, secondary, destructive, ghost } // 버튼 사이즈 enum ShadcnButtonSize { small, medium, large } -// 입력 필드 컴포넌트 -class ShadcnInput extends StatelessWidget { +// ============= 입력 필드 컴포넌트 ============= +class ShadcnInput extends StatefulWidget { final String? label; final String? placeholder; final String? errorText; + final String? helperText; final TextEditingController? controller; final bool obscureText; final TextInputType? keyboardType; @@ -246,12 +333,14 @@ class ShadcnInput extends StatelessWidget { final bool readOnly; final bool enabled; final int? maxLines; + final bool required; const ShadcnInput({ Key? key, this.label, this.placeholder, this.errorText, + this.helperText, this.controller, this.obscureText = false, this.keyboardType, @@ -262,63 +351,124 @@ class ShadcnInput extends StatelessWidget { this.readOnly = false, this.enabled = true, this.maxLines = 1, + this.required = false, }) : super(key: key); + @override + State createState() => _ShadcnInputState(); +} + +class _ShadcnInputState extends State { + bool _isFocused = false; + bool _isHovered = false; + @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (label != null) ...[ - Text(label!, style: ShadcnTheme.labelMedium), + if (widget.label != null) ...[ + Row( + children: [ + Text(widget.label!, style: ShadcnTheme.labelMedium), + if (widget.required) ...[ + const SizedBox(width: 2), + Text('*', style: ShadcnTheme.labelMedium.copyWith( + color: ShadcnTheme.error, + )), + ], + ], + ), const SizedBox(height: ShadcnTheme.spacing1), ], - TextFormField( - controller: controller, - obscureText: obscureText, - keyboardType: keyboardType, - onChanged: onChanged, - onTap: onTap, - readOnly: readOnly, - enabled: enabled, - maxLines: maxLines, - decoration: InputDecoration( - hintText: placeholder, - prefixIcon: prefixIcon, - suffixIcon: suffixIcon, - errorText: errorText, - filled: true, - fillColor: ShadcnTheme.background, - contentPadding: const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing3, - vertical: 10, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - borderSide: const BorderSide(color: ShadcnTheme.input), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - borderSide: const BorderSide(color: ShadcnTheme.input), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - borderSide: const BorderSide(color: ShadcnTheme.ring, width: 2), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - borderSide: const BorderSide(color: ShadcnTheme.destructive), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), - borderSide: const BorderSide( - color: ShadcnTheme.destructive, - width: 2, + MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: Focus( + onFocusChange: (focused) => setState(() => _isFocused = focused), + child: TextFormField( + controller: widget.controller, + obscureText: widget.obscureText, + keyboardType: widget.keyboardType, + onChanged: widget.onChanged, + onTap: widget.onTap, + readOnly: widget.readOnly, + enabled: widget.enabled, + maxLines: widget.maxLines, + style: ShadcnTheme.bodyMedium, + decoration: InputDecoration( + hintText: widget.placeholder, + prefixIcon: widget.prefixIcon, + suffixIcon: widget.suffixIcon, + errorText: widget.errorText, + helperText: widget.helperText, + filled: true, + fillColor: !widget.enabled + ? ShadcnTheme.backgroundSecondary + : _isHovered + ? ShadcnTheme.inputHover + : ShadcnTheme.input, + contentPadding: EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing3, + vertical: widget.maxLines! > 1 + ? ShadcnTheme.spacing3 + : ShadcnTheme.spacing2, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + borderSide: BorderSide( + color: widget.errorText != null + ? ShadcnTheme.error + : ShadcnTheme.inputBorder, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + borderSide: BorderSide( + color: widget.errorText != null + ? ShadcnTheme.error + : _isHovered + ? ShadcnTheme.borderStrong + : ShadcnTheme.inputBorder, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + borderSide: BorderSide( + color: widget.errorText != null + ? ShadcnTheme.error + : ShadcnTheme.inputFocus, + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + borderSide: const BorderSide(color: ShadcnTheme.error), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + borderSide: const BorderSide( + color: ShadcnTheme.error, + width: 2, + ), + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd), + borderSide: BorderSide( + color: ShadcnTheme.border.withValues(alpha: 0.5), + ), + ), + hintStyle: ShadcnTheme.bodyMedium.copyWith( + color: ShadcnTheme.foregroundSubtle, + ), + errorStyle: ShadcnTheme.bodySmall.copyWith( + color: ShadcnTheme.error, + ), + helperStyle: ShadcnTheme.bodySmall.copyWith( + color: ShadcnTheme.foregroundMuted, + ), ), ), - hintStyle: ShadcnTheme.bodyMedium.copyWith( - color: ShadcnTheme.mutedForeground.withValues(alpha: 0.8), - ), ), ), ], @@ -326,17 +476,19 @@ class ShadcnInput extends StatelessWidget { } } -// 배지 컴포넌트 +// ============= 배지 컴포넌트 ============= class ShadcnBadge extends StatelessWidget { final String text; final ShadcnBadgeVariant variant; final ShadcnBadgeSize size; + final Widget? icon; const ShadcnBadge({ Key? key, required this.text, this.variant = ShadcnBadgeVariant.primary, this.size = ShadcnBadgeSize.medium, + this.icon, }) : super(key: key); @override @@ -345,10 +497,22 @@ class ShadcnBadge extends StatelessWidget { padding: _getPadding(), decoration: BoxDecoration( color: _getBackgroundColor(), - borderRadius: BorderRadius.circular(ShadcnTheme.radiusXl), - border: Border.all(color: _getBorderColor()), + borderRadius: BorderRadius.circular(ShadcnTheme.radiusFull), + border: Border.all( + color: _getBorderColor(), + width: variant == ShadcnBadgeVariant.outline ? 1 : 0, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + icon!, + const SizedBox(width: ShadcnTheme.spacing1), + ], + Text(text, style: _getTextStyle()), + ], ), - child: Text(text, style: _getTextStyle()), ); } @@ -356,18 +520,18 @@ class ShadcnBadge extends StatelessWidget { switch (size) { case ShadcnBadgeSize.small: return const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing1, - vertical: ShadcnTheme.spacing1 / 2, + horizontal: ShadcnTheme.spacing2, + vertical: 2, ); case ShadcnBadgeSize.medium: return const EdgeInsets.symmetric( - horizontal: ShadcnTheme.spacing2, - vertical: ShadcnTheme.spacing1, + horizontal: ShadcnTheme.spacing2 + 2, + vertical: 4, ); case ShadcnBadgeSize.large: return const EdgeInsets.symmetric( horizontal: ShadcnTheme.spacing3, - vertical: ShadcnTheme.spacing1, + vertical: 6, ); } } @@ -375,90 +539,160 @@ class ShadcnBadge extends StatelessWidget { Color _getBackgroundColor() { switch (variant) { case ShadcnBadgeVariant.primary: - return ShadcnTheme.primary; + return ShadcnTheme.primaryLight; case ShadcnBadgeVariant.secondary: - return ShadcnTheme.secondary; + return ShadcnTheme.secondaryLight; case ShadcnBadgeVariant.destructive: - return ShadcnTheme.destructive; + return ShadcnTheme.errorLight; case ShadcnBadgeVariant.success: - return ShadcnTheme.success; + return ShadcnTheme.successLight; case ShadcnBadgeVariant.warning: - return ShadcnTheme.warning; + return ShadcnTheme.warningLight; + case ShadcnBadgeVariant.info: + return ShadcnTheme.infoLight; case ShadcnBadgeVariant.outline: return Colors.transparent; + // 비즈니스 상태 배지 + case ShadcnBadgeVariant.equipmentIn: + return ShadcnTheme.equipmentIn.withValues(alpha: 0.1); + case ShadcnBadgeVariant.equipmentOut: + return ShadcnTheme.equipmentOut.withValues(alpha: 0.1); + case ShadcnBadgeVariant.equipmentRent: + return ShadcnTheme.equipmentRent.withValues(alpha: 0.1); + case ShadcnBadgeVariant.companyHeadquarters: + return ShadcnTheme.companyHeadquarters.withValues(alpha: 0.1); + case ShadcnBadgeVariant.companyBranch: + return ShadcnTheme.companyBranch.withValues(alpha: 0.1); + case ShadcnBadgeVariant.companyPartner: + return ShadcnTheme.companyPartner.withValues(alpha: 0.1); + case ShadcnBadgeVariant.companyCustomer: + return ShadcnTheme.companyCustomer.withValues(alpha: 0.1); } } Color _getBorderColor() { switch (variant) { case ShadcnBadgeVariant.outline: - return Colors.black; + return ShadcnTheme.border; default: return Colors.transparent; } } + Color _getTextColor() { + switch (variant) { + case ShadcnBadgeVariant.primary: + return ShadcnTheme.primary; + case ShadcnBadgeVariant.secondary: + return ShadcnTheme.secondaryDark; + case ShadcnBadgeVariant.destructive: + return ShadcnTheme.error; + case ShadcnBadgeVariant.success: + return ShadcnTheme.success; + case ShadcnBadgeVariant.warning: + return ShadcnTheme.warning; + case ShadcnBadgeVariant.info: + return ShadcnTheme.info; + case ShadcnBadgeVariant.outline: + return ShadcnTheme.foreground; + // 비즈니스 상태 텍스트 색상 + case ShadcnBadgeVariant.equipmentIn: + return ShadcnTheme.equipmentIn; + case ShadcnBadgeVariant.equipmentOut: + return ShadcnTheme.equipmentOut; + case ShadcnBadgeVariant.equipmentRent: + return ShadcnTheme.equipmentRent; + case ShadcnBadgeVariant.companyHeadquarters: + return ShadcnTheme.companyHeadquarters; + case ShadcnBadgeVariant.companyBranch: + return ShadcnTheme.companyBranch; + case ShadcnBadgeVariant.companyPartner: + return ShadcnTheme.companyPartner; + case ShadcnBadgeVariant.companyCustomer: + return ShadcnTheme.companyCustomer; + } + } + TextStyle _getTextStyle() { - final Color textColor = - variant == ShadcnBadgeVariant.outline - ? ShadcnTheme.foreground - : variant == ShadcnBadgeVariant.secondary - ? ShadcnTheme.secondaryForeground - : ShadcnTheme.primaryForeground; + final Color textColor = _getTextColor(); switch (size) { case ShadcnBadgeSize.small: - return ShadcnTheme.labelSmall.copyWith(color: textColor); + return ShadcnTheme.caption.copyWith( + color: textColor, + fontWeight: FontWeight.w500, + ); case ShadcnBadgeSize.medium: - return ShadcnTheme.labelMedium.copyWith(color: textColor); + return ShadcnTheme.labelSmall.copyWith( + color: textColor, + fontWeight: FontWeight.w500, + ); case ShadcnBadgeSize.large: - return ShadcnTheme.labelLarge.copyWith(color: textColor); + return ShadcnTheme.labelMedium.copyWith( + color: textColor, + fontWeight: FontWeight.w500, + ); } } } -// 배지 variants +// 배지 variants (비즈니스 상태 추가) enum ShadcnBadgeVariant { primary, secondary, destructive, success, warning, + info, outline, + // 장비 상태 + equipmentIn, + equipmentOut, + equipmentRent, + // 회사 타입 + companyHeadquarters, + companyBranch, + companyPartner, + companyCustomer, } // 배지 사이즈 enum ShadcnBadgeSize { small, medium, large } -// 구분선 컴포넌트 +// ============= 구분선 컴포넌트 ============= class ShadcnSeparator extends StatelessWidget { final Axis direction; final double thickness; final Color? color; + final EdgeInsetsGeometry? margin; const ShadcnSeparator({ Key? key, this.direction = Axis.horizontal, this.thickness = 1.0, this.color, + this.margin, }) : super(key: key); @override Widget build(BuildContext context) { return Container( + margin: margin, width: direction == Axis.horizontal ? double.infinity : thickness, height: direction == Axis.vertical ? double.infinity : thickness, - color: color ?? Colors.black, + color: color ?? ShadcnTheme.divider, ); } } -// 아바타 컴포넌트 +// ============= 아바타 컴포넌트 ============= class ShadcnAvatar extends StatelessWidget { final String? imageUrl; final String? initials; final double size; final Color? backgroundColor; + final Color? textColor; + final bool showBorder; const ShadcnAvatar({ Key? key, @@ -466,6 +700,8 @@ class ShadcnAvatar extends StatelessWidget { this.initials, this.size = 40, this.backgroundColor, + this.textColor, + this.showBorder = true, }) : super(key: key); @override @@ -474,36 +710,166 @@ class ShadcnAvatar extends StatelessWidget { width: size, height: size, decoration: BoxDecoration( - color: backgroundColor ?? ShadcnTheme.muted, + color: backgroundColor ?? ShadcnTheme.backgroundSecondary, shape: BoxShape.circle, - border: Border.all(color: Colors.black), + border: showBorder + ? Border.all(color: ShadcnTheme.border, width: 1) + : null, ), child: ClipOval( - child: - imageUrl != null - ? Image.network( - imageUrl!, - fit: BoxFit.cover, - errorBuilder: - (context, error, stackTrace) => _buildFallback(), - ) - : _buildFallback(), + child: imageUrl != null + ? Image.network( + imageUrl!, + fit: BoxFit.cover, + errorBuilder: (context, error, stackTrace) => _buildFallback(), + ) + : _buildFallback(), ), ); } Widget _buildFallback() { + final displayText = initials?.toUpperCase() ?? '?'; return Container( - color: backgroundColor ?? ShadcnTheme.muted, + color: backgroundColor ?? ShadcnTheme.backgroundSecondary, child: Center( child: Text( - initials ?? '?', - style: ShadcnTheme.labelMedium.copyWith( - color: ShadcnTheme.mutedForeground, + displayText, + style: TextStyle( + color: textColor ?? ShadcnTheme.foregroundSecondary, fontSize: size * 0.4, + fontWeight: FontWeight.w500, ), ), ), ); } } + +// ============= 칩 컴포넌트 ============= +class ShadcnChip extends StatelessWidget { + final String label; + final Color? backgroundColor; + final Color? textColor; + final VoidCallback? onDeleted; + final Widget? avatar; + final bool selected; + + const ShadcnChip({ + Key? key, + required this.label, + this.backgroundColor, + this.textColor, + this.onDeleted, + this.avatar, + this.selected = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: ShadcnTheme.spacing3, + vertical: ShadcnTheme.spacing1, + ), + decoration: BoxDecoration( + color: selected + ? ShadcnTheme.primaryLight + : backgroundColor ?? ShadcnTheme.backgroundSecondary, + borderRadius: BorderRadius.circular(ShadcnTheme.radiusFull), + border: Border.all( + color: selected ? ShadcnTheme.primary : ShadcnTheme.border, + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (avatar != null) ...[ + avatar!, + const SizedBox(width: ShadcnTheme.spacing1), + ], + Text( + label, + style: ShadcnTheme.labelSmall.copyWith( + color: selected + ? ShadcnTheme.primary + : textColor ?? ShadcnTheme.foreground, + ), + ), + if (onDeleted != null) ...[ + const SizedBox(width: ShadcnTheme.spacing1), + GestureDetector( + onTap: onDeleted, + child: Icon( + Icons.close, + size: 14, + color: selected + ? ShadcnTheme.primary + : ShadcnTheme.foregroundMuted, + ), + ), + ], + ], + ), + ); + } +} + +// ============= 프로그레스 바 ============= +class ShadcnProgress extends StatelessWidget { + final double value; // 0.0 ~ 1.0 + final double height; + final Color? backgroundColor; + final Color? valueColor; + final bool showLabel; + + const ShadcnProgress({ + Key? key, + required this.value, + this.height = 8, + this.backgroundColor, + this.valueColor, + this.showLabel = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final percentage = (value * 100).toInt(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showLabel) ...[ + Text( + '$percentage%', + style: ShadcnTheme.labelSmall, + ), + const SizedBox(height: ShadcnTheme.spacing1), + ], + Container( + height: height, + decoration: BoxDecoration( + color: backgroundColor ?? ShadcnTheme.backgroundSecondary, + borderRadius: BorderRadius.circular(height / 2), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(height / 2), + child: Stack( + children: [ + FractionallySizedBox( + widthFactor: value.clamp(0.0, 1.0), + child: Container( + decoration: BoxDecoration( + color: valueColor ?? ShadcnTheme.primary, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/screens/common/theme_shadcn.dart b/lib/screens/common/theme_shadcn.dart index 645c518..b7bd8fd 100644 --- a/lib/screens/common/theme_shadcn.dart +++ b/lib/screens/common/theme_shadcn.dart @@ -1,192 +1,341 @@ import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; -/// shadcn/ui 스타일 테마 시스템 +/// ERP 시스템에 최적화된 색채 심리학 기반 테마 시스템 class ShadcnTheme { - // Teal 기반 색상 시스템 + // ============= 기본 색상 팔레트 ============= + // 배경 및 표면 색상 static const Color background = Color(0xFFFFFFFF); - static const Color foreground = Color(0xFF0F172A); - static const Color card = Color(0xFFFFFFFF); - static const Color cardForeground = Color(0xFF0F172A); - static const Color popover = Color(0xFFFFFFFF); - static const Color popoverForeground = Color(0xFF0F172A); - static const Color primary = Color(0xFF0D9488); // teal-600 - static const Color primaryForeground = Color(0xFFFFFFFF); - static const Color secondary = Color(0xFFF0FDFA); // teal-50 - static const Color secondaryForeground = Color(0xFF134E4A); // teal-900 - static const Color muted = Color(0xFFF1F5F9); // slate-100 - static const Color mutedForeground = Color(0xFF64748B); // slate-500 - static const Color accent = Color(0xFF14B8A6); // teal-500 - static const Color accentForeground = Color(0xFFFFFFFF); - static const Color destructive = Color(0xFFEF4444); // red-500 - static const Color destructiveForeground = Color(0xFFFFFFFF); - static const Color border = Color(0xFFE5E7EB); // gray-200 (기본 border는 연한 회색) - static const Color input = Color(0xFFE5E7EB); // gray-200 - static const Color ring = Color(0xFF14B8A6); // teal-500 - static const Color radius = Color(0xFF000000); // 사용하지 않음 - - // Teal 그라데이션 색상 - static const Color gradient1 = Color(0xFF14B8A6); // teal-500 - static const Color gradient2 = Color(0xFF0D9488); // teal-600 - static const Color gradient3 = Color(0xFF0F766E); // teal-700 - - // 상태 색상 - static const Color success = Color(0xFF10B981); // emerald-500 - static const Color warning = Color(0xFFF59E0B); // amber-500 - static const Color error = Color(0xFFEF4444); // red-500 - static const Color info = Color(0xFF0891B2); // cyan-600 + static const Color backgroundSecondary = Color(0xFFF9FAFB); // 보조 배경 + static const Color surface = Color(0xFFFFFFFF); + static const Color surfaceHover = Color(0xFFF3F4F6); // 호버 상태 - // 추가 색상 (회사 구분용) - static const Color blue = Color(0xFF3B82F6); // blue-500 - static const Color purple = Color(0xFF8B5CF6); // purple-500 - static const Color green = Color(0xFF22C55E); // green-500 - - // 그림자 설정 - static List get cardShadow => [ + // 텍스트 색상 + static const Color foreground = Color(0xFF111827); // 주요 텍스트 (진한 검정) + static const Color foregroundSecondary = Color(0xFF374151); // 보조 텍스트 + static const Color foregroundMuted = Color(0xFF6B7280); // 비활성 텍스트 + static const Color foregroundSubtle = Color(0xFF9CA3AF); // 희미한 텍스트 + + // Primary 색상 (신뢰감 있는 블루) + static const Color primary = Color(0xFF2563EB); // blue-600 + static const Color primaryDark = Color(0xFF1E40AF); // blue-800 + static const Color primaryLight = Color(0xFFDBEAFE); // blue-100 + static const Color primaryForeground = Color(0xFFFFFFFF); + + // Secondary 색상 (중립 그레이) + static const Color secondary = Color(0xFF6B7280); // gray-500 + static const Color secondaryDark = Color(0xFF374151); // gray-700 + static const Color secondaryLight = Color(0xFFF9FAFB); // gray-50 + static const Color secondaryForeground = Color(0xFF111827); + + // ============= 시맨틱 색상 ============= + static const Color success = Color(0xFF059669); // emerald-600 + static const Color successLight = Color(0xFFD1FAE5); // emerald-100 + static const Color successForeground = Color(0xFFFFFFFF); + + static const Color warning = Color(0xFFD97706); // amber-600 + static const Color warningLight = Color(0xFFFEF3C7); // amber-100 + static const Color warningForeground = Color(0xFFFFFFFF); + + static const Color error = Color(0xFFDC2626); // red-600 + static const Color errorLight = Color(0xFFFEE2E2); // red-100 + static const Color errorForeground = Color(0xFFFFFFFF); + + static const Color info = Color(0xFF0891B2); // cyan-600 + static const Color infoLight = Color(0xFFCFFAFE); // cyan-100 + static const Color infoForeground = Color(0xFFFFFFFF); + + // ============= 비즈니스 상태 색상 ============= + // 회사 구분 색상 + static const Color companyHeadquarters = Color(0xFF2563EB); // 본사 - Primary Blue (권위) + static const Color companyBranch = Color(0xFF7C3AED); // 지점 - Purple (연결성) + static const Color companyPartner = Color(0xFF059669); // 파트너사 - Green (협력) + static const Color companyCustomer = Color(0xFFEA580C); // 고객사 - Orange (활력) + + // 장비 상태 색상 + static const Color equipmentIn = Color(0xFF059669); // 입고 - Green (진입/추가) + static const Color equipmentOut = Color(0xFF0891B2); // 출고 - Cyan (이동/프로세스) + static const Color equipmentRent = Color(0xFF7C3AED); // 대여 - Purple (임시 상태) + static const Color equipmentDisposal = Color(0xFF6B7280); // 폐기 - Gray (비활성) + static const Color equipmentRepair = Color(0xFFD97706); // 수리중 - Amber (주의 필요) + static const Color equipmentUnknown = Color(0xFF9CA3AF); // 알수없음 - Light Gray + + // ============= UI 요소 색상 ============= + static const Color border = Color(0xFFE5E7EB); // gray-200 + static const Color borderStrong = Color(0xFFD1D5DB); // gray-300 + static const Color borderFocus = Color(0xFF2563EB); // primary + static const Color divider = Color(0xFFF3F4F6); // gray-100 + + static const Color card = Color(0xFFFFFFFF); + static const Color cardForeground = Color(0xFF111827); + static const Color cardHover = Color(0xFFF9FAFB); + + static const Color input = Color(0xFFFFFFFF); + static const Color inputBorder = Color(0xFFD1D5DB); // gray-300 + static const Color inputHover = Color(0xFFF9FAFB); + static const Color inputFocus = Color(0xFF2563EB); + + // 기존 호환성을 위한 별칭 + static const Color destructive = error; + static const Color destructiveForeground = errorForeground; + static const Color muted = backgroundSecondary; + static const Color mutedForeground = foregroundMuted; + static const Color accent = primary; + static const Color accentForeground = primaryForeground; + static const Color ring = primaryDark; + static const Color popover = card; + static const Color popoverForeground = cardForeground; + + // Teal 그라데이션 색상 (기존 호환) + static const Color gradient1 = primary; + static const Color gradient2 = primaryDark; + static const Color gradient3 = Color(0xFF1D4ED8); // blue-700 + + // 추가 색상 (기존 호환) + static const Color blue = primary; + static const Color purple = companyBranch; + static const Color green = companyPartner; + + static const Color radius = Color(0xFF000000); // 사용하지 않음 + + // ============= 그림자 시스템 ============= + static List get shadowXs => [ BoxShadow( - color: primary.withValues(alpha: 0.08), - blurRadius: 8, - offset: const Offset(0, 2), - ), - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 16, - offset: const Offset(0, 8), + color: Colors.black.withValues(alpha: 0.05), + blurRadius: 2, + offset: const Offset(0, 1), ), ]; - - static List get buttonShadow => [ + + static List get shadowSm => [ BoxShadow( - color: primary.withValues(alpha: 0.2), + color: Colors.black.withValues(alpha: 0.05), blurRadius: 4, offset: const Offset(0, 2), ), ]; - - // 간격 시스템 + + static List get shadowMd => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ]; + + static List get shadowLg => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.10), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ]; + + static List get shadowXl => [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 24, + offset: const Offset(0, 12), + ), + ]; + + // 카드 및 버튼 그림자 (기존 호환) + static List get cardShadow => shadowMd; + static List get buttonShadow => shadowSm; + + // ============= 간격 시스템 (8px 기반) ============= + static const double spacing0 = 0.0; static const double spacing1 = 4.0; static const double spacing2 = 8.0; static const double spacing3 = 12.0; static const double spacing4 = 16.0; static const double spacing5 = 20.0; static const double spacing6 = 24.0; + static const double spacing7 = 28.0; static const double spacing8 = 32.0; + static const double spacing9 = 36.0; static const double spacing10 = 40.0; static const double spacing12 = 48.0; + static const double spacing14 = 56.0; static const double spacing16 = 64.0; static const double spacing20 = 80.0; - - // 라운드 설정 + static const double spacing24 = 96.0; + + // ============= 라운드 설정 ============= static const double radiusNone = 0.0; - static const double radiusSm = 2.0; + static const double radiusXs = 2.0; + static const double radiusSm = 4.0; static const double radiusMd = 6.0; static const double radiusLg = 8.0; static const double radiusXl = 12.0; static const double radius2xl = 16.0; static const double radius3xl = 24.0; static const double radiusFull = 9999.0; - - // 타이포그래피 시스템 (통일된 크기) + + // ============= 타이포그래피 시스템 ============= + // 헤딩 스타일 static TextStyle get headingH1 => GoogleFonts.inter( - fontSize: 32, + fontSize: 36, fontWeight: FontWeight.w700, color: foreground, letterSpacing: -0.02, + height: 1.2, ); - + static TextStyle get headingH2 => GoogleFonts.inter( + fontSize: 28, + fontWeight: FontWeight.w600, + color: foreground, + letterSpacing: -0.01, + height: 1.3, + ); + + static TextStyle get headingH3 => GoogleFonts.inter( fontSize: 24, fontWeight: FontWeight.w600, color: foreground, letterSpacing: -0.01, + height: 1.35, ); - - static TextStyle get headingH3 => GoogleFonts.inter( + + static TextStyle get headingH4 => GoogleFonts.inter( fontSize: 20, fontWeight: FontWeight.w500, color: foreground, - letterSpacing: -0.01, + letterSpacing: 0, + height: 1.4, ); - - static TextStyle get headingH4 => GoogleFonts.inter( + + static TextStyle get headingH5 => GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w500, + color: foreground, + letterSpacing: 0, + height: 1.4, + ); + + static TextStyle get headingH6 => GoogleFonts.inter( fontSize: 16, fontWeight: FontWeight.w500, color: foreground, letterSpacing: 0, + height: 1.5, ); - + + // 본문 스타일 static TextStyle get bodyLarge => GoogleFonts.inter( - fontSize: 14, + fontSize: 16, fontWeight: FontWeight.w400, color: foreground, letterSpacing: 0, + height: 1.6, ); - + static TextStyle get bodyMedium => GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w400, color: foreground, letterSpacing: 0, + height: 1.6, ); - + static TextStyle get bodySmall => GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w400, + color: foregroundSecondary, + letterSpacing: 0, + height: 1.5, + ); + + static TextStyle get bodyXs => GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w400, - color: mutedForeground, + color: foregroundMuted, letterSpacing: 0, + height: 1.5, ); - + + // 기타 스타일 static TextStyle get bodyMuted => GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w400, - color: mutedForeground, + color: foregroundMuted, letterSpacing: 0, + height: 1.6, ); - + static TextStyle get labelLarge => GoogleFonts.inter( fontSize: 14, fontWeight: FontWeight.w500, color: foreground, - letterSpacing: 0, + letterSpacing: 0.02, + height: 1.4, ); - + static TextStyle get labelMedium => GoogleFonts.inter( - fontSize: 12, + fontSize: 13, fontWeight: FontWeight.w500, color: foreground, - letterSpacing: 0, + letterSpacing: 0.02, + height: 1.4, ); - + static TextStyle get labelSmall => GoogleFonts.inter( - fontSize: 10, + fontSize: 12, fontWeight: FontWeight.w500, - color: mutedForeground, - letterSpacing: 0, + color: foregroundSecondary, + letterSpacing: 0.02, + height: 1.4, ); - - // Flutter 테마 데이터 + + static TextStyle get caption => GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w400, + color: foregroundMuted, + letterSpacing: 0.02, + height: 1.4, + ); + + // 코드/모노스페이스 + static TextStyle get code => GoogleFonts.jetBrainsMono( + fontSize: 13, + fontWeight: FontWeight.w400, + color: foreground, + letterSpacing: 0, + height: 1.5, + ); + + // ============= Flutter 테마 데이터 ============= static ThemeData get lightTheme { return ThemeData( useMaterial3: true, colorScheme: const ColorScheme.light( primary: primary, + primaryContainer: primaryLight, secondary: secondary, + secondaryContainer: secondaryLight, surface: background, surfaceContainerHighest: card, onSurface: foreground, onPrimary: primaryForeground, onSecondary: secondaryForeground, - error: destructive, - onError: destructiveForeground, + error: error, + errorContainer: errorLight, + onError: errorForeground, outline: border, - outlineVariant: input, + outlineVariant: divider, ), scaffoldBackgroundColor: background, textTheme: TextTheme( - headlineLarge: headingH1, - headlineMedium: headingH2, - headlineSmall: headingH3, - titleLarge: headingH4, + displayLarge: headingH1, + displayMedium: headingH2, + displaySmall: headingH3, + headlineLarge: headingH3, + headlineMedium: headingH4, + headlineSmall: headingH5, + titleLarge: headingH6, + titleMedium: labelLarge, + titleSmall: labelMedium, bodyLarge: bodyLarge, bodyMedium: bodyMedium, bodySmall: bodySmall, @@ -198,11 +347,16 @@ class ShadcnTheme { backgroundColor: background, foregroundColor: foreground, elevation: 0, - scrolledUnderElevation: 1, - shadowColor: Colors.black.withValues(alpha: 0.1), + scrolledUnderElevation: 0, + shadowColor: Colors.transparent, surfaceTintColor: Colors.transparent, - titleTextStyle: headingH4, - iconTheme: const IconThemeData(color: foreground), + centerTitle: false, + titleTextStyle: headingH5, + toolbarHeight: 64, + iconTheme: const IconThemeData( + color: foregroundSecondary, + size: 20, + ), ), cardTheme: CardThemeData( color: card, @@ -211,86 +365,234 @@ class ShadcnTheme { borderRadius: BorderRadius.circular(radiusLg), side: const BorderSide(color: border, width: 1), ), - shadowColor: Colors.black.withValues(alpha: 0.05), + shadowColor: Colors.transparent, + margin: EdgeInsets.zero, ), elevatedButtonTheme: ElevatedButtonThemeData( style: ElevatedButton.styleFrom( backgroundColor: primary, foregroundColor: primaryForeground, + disabledBackgroundColor: backgroundSecondary, + disabledForegroundColor: foregroundMuted, elevation: 0, shadowColor: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radiusMd), ), + minimumSize: const Size(64, 40), padding: const EdgeInsets.symmetric( - horizontal: spacing4, + horizontal: spacing6, vertical: spacing2, ), - textStyle: labelMedium, + textStyle: labelMedium.copyWith(fontWeight: FontWeight.w500), ), ), outlinedButtonTheme: OutlinedButtonThemeData( style: OutlinedButton.styleFrom( foregroundColor: foreground, - side: const BorderSide(color: border), + disabledForegroundColor: foregroundMuted, + side: const BorderSide(color: border, width: 1), elevation: 0, shadowColor: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radiusMd), ), + minimumSize: const Size(64, 40), padding: const EdgeInsets.symmetric( - horizontal: spacing4, + horizontal: spacing6, vertical: spacing2, ), - textStyle: labelMedium, + textStyle: labelMedium.copyWith(fontWeight: FontWeight.w500), ), ), textButtonTheme: TextButtonThemeData( style: TextButton.styleFrom( - foregroundColor: foreground, + foregroundColor: primary, + disabledForegroundColor: foregroundMuted, elevation: 0, shadowColor: Colors.transparent, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(radiusMd), ), + minimumSize: const Size(64, 40), padding: const EdgeInsets.symmetric( horizontal: spacing4, vertical: spacing2, ), - textStyle: labelMedium, + textStyle: labelMedium.copyWith(fontWeight: FontWeight.w500), ), ), inputDecorationTheme: InputDecorationTheme( filled: true, - fillColor: background, + fillColor: input, + hoverColor: inputHover, contentPadding: const EdgeInsets.symmetric( horizontal: spacing3, - vertical: spacing2, + vertical: spacing3, ), border: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), - borderSide: const BorderSide(color: input), + borderSide: const BorderSide(color: inputBorder, width: 1), ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), - borderSide: const BorderSide(color: input), + borderSide: const BorderSide(color: inputBorder, width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), - borderSide: const BorderSide(color: ring, width: 2), + borderSide: const BorderSide(color: inputFocus, width: 2), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), - borderSide: const BorderSide(color: destructive), + borderSide: const BorderSide(color: error, width: 1), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(radiusMd), - borderSide: const BorderSide(color: destructive, width: 2), + borderSide: const BorderSide(color: error, width: 2), ), - hintStyle: bodyMedium.copyWith(color: mutedForeground), - labelStyle: labelMedium, + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(radiusMd), + borderSide: BorderSide(color: border.withValues(alpha: 0.5), width: 1), + ), + hintStyle: bodyMedium.copyWith(color: foregroundSubtle), + labelStyle: labelMedium.copyWith(color: foregroundSecondary), + helperStyle: bodySmall.copyWith(color: foregroundMuted), + errorStyle: bodySmall.copyWith(color: error), + prefixIconColor: foregroundMuted, + suffixIconColor: foregroundMuted, + ), + dividerTheme: const DividerThemeData( + color: divider, + thickness: 1, + space: 1, + ), + chipTheme: ChipThemeData( + backgroundColor: backgroundSecondary, + disabledColor: backgroundSecondary.withValues(alpha: 0.5), + selectedColor: primaryLight, + secondarySelectedColor: primaryLight, + labelStyle: labelSmall, + secondaryLabelStyle: labelSmall, + padding: const EdgeInsets.symmetric(horizontal: spacing2, vertical: spacing1), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusFull), + side: const BorderSide(color: Colors.transparent), + ), + ), + dialogTheme: DialogThemeData( + backgroundColor: card, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusLg), + ), + titleTextStyle: headingH5, + contentTextStyle: bodyMedium, + ), + tooltipTheme: TooltipThemeData( + decoration: BoxDecoration( + color: foreground, + borderRadius: BorderRadius.circular(radiusMd), + ), + textStyle: bodySmall.copyWith(color: background), + padding: const EdgeInsets.symmetric( + horizontal: spacing3, + vertical: spacing2, + ), + ), + snackBarTheme: SnackBarThemeData( + backgroundColor: foreground, + contentTextStyle: bodyMedium.copyWith(color: background), + actionTextColor: primaryLight, + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(radiusMd), + ), + ), + dataTableTheme: DataTableThemeData( + headingRowColor: WidgetStateProperty.all(backgroundSecondary), + headingTextStyle: labelMedium.copyWith( + color: foreground, + fontWeight: FontWeight.w600, + ), + dataTextStyle: bodyMedium, + dividerThickness: 1, + horizontalMargin: spacing4, + columnSpacing: spacing6, + headingRowHeight: 48, + dataRowMinHeight: 56, + dataRowMaxHeight: 56, ), - dividerTheme: const DividerThemeData(color: border, thickness: 1), ); } -} + + // ============= 유틸리티 메서드 ============= + /// 회사 타입에 따른 색상 반환 + static Color getCompanyColor(String type) { + switch (type.toLowerCase()) { + case '본사': + case 'headquarters': + return companyHeadquarters; + case '지점': + case 'branch': + return companyBranch; + case '파트너사': + case 'partner': + return companyPartner; + case '고객사': + case 'customer': + return companyCustomer; + default: + return secondary; + } + } + + /// 장비 상태에 따른 색상 반환 + static Color getEquipmentStatusColor(String status) { + switch (status.toLowerCase()) { + case '입고': + case 'in': + return equipmentIn; + case '출고': + case 'out': + return equipmentOut; + case '대여': + case 'rent': + return equipmentRent; + case '폐기': + case 'disposal': + return equipmentDisposal; + case '수리중': + case 'repair': + return equipmentRepair; + case '알수없음': + case 'unknown': + return equipmentUnknown; + default: + return secondary; + } + } + + /// 상태별 배경색 반환 (연한 버전) + static Color getStatusBackgroundColor(String status) { + switch (status.toLowerCase()) { + case 'success': + case '성공': + case '완료': + return successLight; + case 'warning': + case '경고': + case '주의': + return warningLight; + case 'error': + case '오류': + case '실패': + return errorLight; + case 'info': + case '정보': + case '알림': + return infoLight; + default: + return backgroundSecondary; + } + } +} \ No newline at end of file