Files
superport/lib/screens/common/app_layout.dart
JiWoong Sul 655d473413
Some checks failed
Flutter Test & Quality Check / Test on macos-latest (push) Has been cancelled
Flutter Test & Quality Check / Test on ubuntu-latest (push) Has been cancelled
Flutter Test & Quality Check / Build APK (push) Has been cancelled
web: migrate health notifications to js_interop; add browser hook
- Replace dart:js with package:js in health_check_service_web.dart\n- Implement showHealthCheckNotification in web/index.html\n- Pin js dependency to ^0.6.7 for flutter_secure_storage_web compatibility

auth: harden AuthInterceptor + tests

- Allow overrideAuthRepository injection for testing\n- Normalize imports to package: paths\n- Add unit test covering token attach, 401→refresh→retry, and failure path\n- Add integration test skeleton gated by env vars

ui/data: map User.companyName to list column

- Add companyName to domain User\n- Map UserDto.company?.name\n- Render companyName in user_list

cleanup: remove legacy equipment table + unused code; minor warnings

- Remove _buildFlexibleTable and unused helpers\n- Remove unused zipcode details and cache retry constant\n- Fix null-aware and non-null assertions\n- Address child-last warnings in administrator dialog

docs: update AGENTS.md session context
2025-09-08 17:39:00 +09:00

1393 lines
49 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/vendor/vendor_list_screen.dart';
import 'package:superport/screens/vendor/controllers/vendor_controller.dart';
import 'package:superport/screens/model/model_list_screen.dart';
import 'package:superport/screens/zipcode/zipcode_search_screen.dart';
import 'package:superport/screens/zipcode/controllers/zipcode_controller.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/warehouse_location/warehouse_location_list.dart';
import 'package:superport/screens/inventory/inventory_history_screen.dart';
import 'package:superport/screens/maintenance/maintenance_schedule_screen.dart';
import 'package:superport/screens/maintenance/maintenance_alert_dashboard.dart';
import 'package:superport/screens/maintenance/maintenance_history_screen.dart' as maint;
import 'package:superport/screens/maintenance/controllers/maintenance_controller.dart';
import 'package:superport/screens/rent/rent_list_screen.dart';
import 'package:superport/screens/rent/controllers/rent_controller.dart';
import 'package:superport/services/auth_service.dart';
import 'package:superport/core/services/lookups_service.dart';
import 'package:superport/injection_container.dart' as di;
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({super.key, this.initialRoute = Routes.home});
@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 LookupsService _lookupsService;
late Animation<double> _sidebarAnimation;
int _expiringMaintenanceCount = 0; // 30일 내 만료 예정 유지보수 수
// 레이아웃 상수 (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>();
_lookupsService = GetIt.instance<LookupsService>();
_loadCurrentUser();
_loadMaintenanceAlerts();
_initializeLookupData(); // Lookup 데이터 초기화
}
Future<void> _loadCurrentUser() async {
try {
// 서버에서 최신 관리자 정보 가져오기
final result = await _authService.getCurrentAdminFromServer();
result.fold(
(failure) {
print('[AppLayout] 서버에서 관리자 정보 로드 실패: ${failure.message}');
// 실패 시 로컬 스토리지에서 캐시된 정보 사용
_loadCurrentUserFromLocal();
},
(user) {
if (mounted) {
setState(() {
_currentUser = user;
});
print('[AppLayout] 서버에서 관리자 정보 로드 성공: ${user.name} (${user.email})');
}
},
);
} catch (e) {
print('[AppLayout] 관리자 정보 로드 중 예외 발생: $e');
// 예외 발생 시 로컬 스토리지에서 캐시된 정보 사용
_loadCurrentUserFromLocal();
}
}
/// 로컬 스토리지에서 캐시된 사용자 정보 로드 (fallback)
Future<void> _loadCurrentUserFromLocal() async {
final user = await _authService.getCurrentUser();
if (mounted) {
setState(() {
_currentUser = user;
});
print('[AppLayout] 로컬에서 관리자 정보 로드: ${user?.name ?? 'Unknown'}');
}
}
Future<void> _loadMaintenanceAlerts() async {
try {
print('[DEBUG] 유지보수 알림 정보 로드 시작...');
// TODO: MaintenanceController를 통해 알림 정보 로드
// 현재는 임시로 0으로 설정
if (mounted) {
setState(() {
_expiringMaintenanceCount = 0;
print('[DEBUG] 유지보수 알림 상태 업데이트 완료: $_expiringMaintenanceCount');
});
}
} catch (e) {
print('[ERROR] 유지보수 알림 정보 로드 중 예외 발생: $e');
print('[ERROR] 스택 트레이스: ${StackTrace.current}');
}
}
/// Lookup 데이터 초기화 (앱 시작 시 한 번만 호출)
Future<void> _initializeLookupData() async {
try {
print('[DEBUG] Lookups 서비스 초기화 시작...');
if (!_lookupsService.isInitialized) {
final result = await _lookupsService.initialize();
result.fold(
(failure) {
print('[ERROR] Lookups 초기화 실패: ${failure.message}');
},
(success) {
print('[DEBUG] Lookups 서비스 초기화 성공!');
final stats = _lookupsService.getCacheStats();
print('[DEBUG] - 제조사: ${stats['manufacturers_count']}');
print('[DEBUG] - 장비명: ${stats['equipment_names_count']}');
print('[DEBUG] - 장비 카테고리: ${stats['equipment_categories_count']}');
print('[DEBUG] - 장비 상태: ${stats['equipment_statuses_count']}');
},
);
} else {
print('[DEBUG] Lookups 서비스 이미 초기화됨 (캐시 사용)');
}
} catch (e) {
print('[ERROR] Lookups 초기화 중 예외 발생: $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 ChangeNotifierProvider(
create: (context) => di.sl<VendorController>(),
child: const VendorListScreen(),
);
case Routes.vendor:
return ChangeNotifierProvider(
create: (context) => di.sl<VendorController>(),
child: const VendorListScreen(),
);
case Routes.model:
return const ModelListScreen();
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();
// License 시스템이 Maintenance로 대체됨
case Routes.maintenance: // 메인 진입점을 알림 대시보드로 변경
return ChangeNotifierProvider(
create: (_) => GetIt.instance<MaintenanceController>(),
child: const MaintenanceAlertDashboard(),
);
case Routes.maintenanceSchedule: // 일정관리는 별도 라우트 유지
return ChangeNotifierProvider(
create: (_) => GetIt.instance<MaintenanceController>(),
child: const MaintenanceScheduleScreen(),
);
case Routes.maintenanceAlert:
return ChangeNotifierProvider(
create: (_) => GetIt.instance<MaintenanceController>(),
child: const MaintenanceAlertDashboard(),
);
case Routes.maintenanceHistory:
return ChangeNotifierProvider(
create: (_) => GetIt.instance<MaintenanceController>(),
child: const maint.MaintenanceHistoryScreen(),
);
case Routes.warehouseLocation:
return const WarehouseLocationList();
case Routes.zipcode:
return ChangeNotifierProvider(
create: (context) => di.sl<ZipcodeController>(),
child: const ZipcodeSearchScreen(),
);
case Routes.inventory:
case Routes.inventoryHistory:
return const InventoryHistoryScreen();
case Routes.rent:
return ChangeNotifierProvider(
create: (_) => GetIt.instance<RentController>(),
child: const RentListScreen(),
);
case '/test/api':
// Navigator를 사용하여 별도 화면으로 이동
WidgetsBinding.instance.addPostFrameCallback((_) {
Navigator.pushNamed(context, '/test/api');
});
return const Center(child: CircularProgressIndicator());
default:
return ChangeNotifierProvider(
create: (context) => di.sl<VendorController>(),
child: const VendorListScreen(),
);
}
}
/// 경로 변경 메서드
void _navigateTo(String route) {
setState(() {
_currentRoute = route;
});
// 유지보수 화면으로 이동할 때 알림 정보 새로고침
if (route == Routes.maintenance || route == Routes.maintenanceAlert) {
_loadMaintenanceAlerts();
}
}
/// 사이드바 토글
void _toggleSidebar() {
setState(() {
_sidebarCollapsed = !_sidebarCollapsed;
});
if (_sidebarCollapsed) {
_sidebarAnimationController.forward();
} else {
_sidebarAnimationController.reverse();
}
}
/// 현재 페이지 제목 가져오기
@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,
expiringMaintenanceCount: _expiringMaintenanceCount,
);
}
/// 프로필 메뉴 표시
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.lock_outline,
title: '비밀번호 변경',
onTap: () {
Navigator.pop(context);
_showChangePasswordDialog(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,
),
],
),
),
);
}
/// 비밀번호 변경 다이얼로그
void _showChangePasswordDialog(BuildContext context) {
final oldPasswordController = TextEditingController();
final newPasswordController = TextEditingController();
final confirmPasswordController = TextEditingController();
bool isLoading = false;
bool obscureOldPassword = true;
bool obscureNewPassword = true;
bool obscureConfirmPassword = true;
showDialog(
context: context,
barrierDismissible: false,
builder: (context) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
backgroundColor: ShadcnTheme.background,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusLg),
),
title: Row(
children: [
Icon(
Icons.lock_outline,
color: ShadcnTheme.primary,
size: 24,
),
const SizedBox(width: ShadcnTheme.spacing3),
Text(
'비밀번호 변경',
style: ShadcnTheme.headingH5,
),
],
),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'보안을 위해 기존 비밀번호를 입력하고 새 비밀번호를 설정해주세요.',
style: ShadcnTheme.bodySmall.copyWith(
color: ShadcnTheme.foregroundMuted,
),
),
const SizedBox(height: ShadcnTheme.spacing6),
// 기존 비밀번호
Text(
'기존 비밀번호',
style: ShadcnTheme.labelMedium,
),
const SizedBox(height: ShadcnTheme.spacing2),
TextFormField(
controller: oldPasswordController,
obscureText: obscureOldPassword,
decoration: InputDecoration(
hintText: '현재 사용중인 비밀번호를 입력하세요',
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureOldPassword = !obscureOldPassword;
});
},
icon: Icon(
obscureOldPassword ? Icons.visibility_off : Icons.visibility,
color: ShadcnTheme.foregroundMuted,
),
),
),
),
const SizedBox(height: ShadcnTheme.spacing4),
// 새 비밀번호
Text(
'새 비밀번호',
style: ShadcnTheme.labelMedium,
),
const SizedBox(height: ShadcnTheme.spacing2),
TextFormField(
controller: newPasswordController,
obscureText: obscureNewPassword,
decoration: InputDecoration(
hintText: '8자 이상의 새 비밀번호를 입력하세요',
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureNewPassword = !obscureNewPassword;
});
},
icon: Icon(
obscureNewPassword ? Icons.visibility_off : Icons.visibility,
color: ShadcnTheme.foregroundMuted,
),
),
),
),
const SizedBox(height: ShadcnTheme.spacing4),
// 새 비밀번호 확인
Text(
'새 비밀번호 확인',
style: ShadcnTheme.labelMedium,
),
const SizedBox(height: ShadcnTheme.spacing2),
TextFormField(
controller: confirmPasswordController,
obscureText: obscureConfirmPassword,
decoration: InputDecoration(
hintText: '새 비밀번호를 다시 입력하세요',
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureConfirmPassword = !obscureConfirmPassword;
});
},
icon: Icon(
obscureConfirmPassword ? Icons.visibility_off : Icons.visibility,
color: ShadcnTheme.foregroundMuted,
),
),
),
),
],
),
),
actions: [
ShadcnButton(
text: '취소',
onPressed: isLoading ? null : () {
Navigator.of(context).pop();
},
variant: ShadcnButtonVariant.secondary,
),
ShadcnButton(
text: isLoading ? '변경 중...' : '변경하기',
onPressed: isLoading ? null : () async {
// 유효성 검사
if (oldPasswordController.text.isEmpty) {
_showSnackBar(context, '기존 비밀번호를 입력해주세요.', isError: true);
return;
}
if (newPasswordController.text.length < 8) {
_showSnackBar(context, '새 비밀번호는 8자 이상이어야 합니다.', isError: true);
return;
}
if (newPasswordController.text != confirmPasswordController.text) {
_showSnackBar(context, '새 비밀번호가 일치하지 않습니다.', isError: true);
return;
}
setState(() {
isLoading = true;
});
try {
// AuthService.changePassword API 호출
final result = await _authService.changePassword(
oldPassword: oldPasswordController.text,
newPassword: newPasswordController.text,
);
result.fold(
(failure) {
if (context.mounted) {
_showSnackBar(context, failure.message, isError: true);
}
},
(messageResponse) {
if (context.mounted) {
Navigator.of(context).pop();
_showSnackBar(context, messageResponse.message);
}
},
);
} catch (e) {
if (context.mounted) {
_showSnackBar(context, '비밀번호 변경 중 오류가 발생했습니다.', isError: true);
}
} finally {
if (mounted) {
setState(() {
isLoading = false;
});
}
}
},
variant: ShadcnButtonVariant.primary,
icon: isLoading ? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
ShadcnTheme.primaryForeground,
),
),
) : Icon(Icons.check, size: 18),
),
],
),
),
);
}
/// 스낵바 표시 헬퍼 메서드
void _showSnackBar(BuildContext context, String message, {bool isError = false}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: isError ? ShadcnTheme.error : ShadcnTheme.success,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
),
);
}
}
/// 재설계된 사이드바 메뉴 (접기/펼치기 지원)
class SidebarMenu extends StatelessWidget {
final String currentRoute;
final Function(String) onRouteChanged;
final bool collapsed;
final int expiringMaintenanceCount;
const SidebarMenu({
super.key,
required this.currentRoute,
required this.onRouteChanged,
required this.collapsed,
required this.expiringMaintenanceCount,
});
@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.factory_outlined,
title: '벤더 관리',
route: Routes.vendor,
isActive: currentRoute == Routes.vendor,
badge: null,
),
_buildMenuItem(
icon: Icons.category_outlined,
title: '모델 관리',
route: Routes.model,
isActive: currentRoute == Routes.model,
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.history,
title: '재고 이력',
route: Routes.inventoryHistory,
isActive: [
Routes.inventory,
Routes.inventoryHistory,
].contains(currentRoute),
badge: null,
),
_buildMenuItem(
icon: Icons.warehouse_outlined,
title: '입고지 관리',
route: Routes.warehouseLocation,
isActive: currentRoute == Routes.warehouseLocation,
badge: null,
),
_buildMenuItem(
icon: Icons.location_on_outlined,
title: '우편번호 검색',
route: Routes.zipcode,
isActive: currentRoute == Routes.zipcode,
badge: null,
),
_buildMenuItem(
icon: Icons.business_outlined,
title: '회사 관리',
route: Routes.company,
isActive: currentRoute == Routes.company,
badge: null,
),
_buildMenuItem(
icon: Icons.people_outlined,
title: '사용자 관리',
route: Routes.user,
isActive: currentRoute == Routes.user,
badge: null,
),
_buildMenuItem(
icon: Icons.build_circle_outlined,
title: '유지보수 관리',
route: Routes.maintenance,
isActive: currentRoute == Routes.maintenance ||
currentRoute == Routes.maintenanceSchedule ||
currentRoute == Routes.maintenanceAlert ||
currentRoute == Routes.maintenanceHistory,
badge: null,
hasSubMenu: true,
subMenuItems: collapsed ? [] : [
_buildSubMenuItem(
title: '알림 대시보드',
route: Routes.maintenanceAlert,
isActive: currentRoute == Routes.maintenanceAlert,
),
_buildSubMenuItem(
title: '일정 관리',
route: Routes.maintenanceSchedule,
isActive: currentRoute == Routes.maintenanceSchedule,
),
_buildSubMenuItem(
title: '이력 조회',
route: Routes.maintenanceHistory,
isActive: currentRoute == Routes.maintenanceHistory,
),
],
),
_buildMenuItem(
icon: Icons.calendar_month_outlined,
title: '임대 관리',
route: Routes.rent,
isActive: currentRoute == Routes.rent,
badge: 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,
bool hasSubMenu = false,
List<Widget> subMenuItems = const [],
}) {
return Column(
children: [
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,
),
),
),
],
],
),
),
),
),
if (hasSubMenu && subMenuItems.isNotEmpty) ...subMenuItems,
],
);
}
Widget _buildSubMenuItem({
required String title,
required String route,
required bool isActive,
}) {
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.only(left: 40, bottom: 4),
child: InkWell(
onTap: () => onRouteChanged(route),
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: ShadcnTheme.spacing3,
vertical: ShadcnTheme.spacing2,
),
decoration: BoxDecoration(
color: isActive
? ShadcnTheme.primaryLight.withValues(alpha: 0.5)
: Colors.transparent,
borderRadius: BorderRadius.circular(ShadcnTheme.radiusMd),
),
child: Row(
children: [
Container(
width: 4,
height: 4,
decoration: BoxDecoration(
color: isActive
? ShadcnTheme.primary
: ShadcnTheme.foregroundSecondary,
shape: BoxShape.circle,
),
),
const SizedBox(width: ShadcnTheme.spacing3),
Expanded(
child: Text(
title,
style: ShadcnTheme.bodySmall.copyWith(
color: isActive
? ShadcnTheme.primary
: ShadcnTheme.foreground,
fontWeight: isActive ? FontWeight.w600 : FontWeight.w400,
),
),
),
],
),
),
),
);
}
/// 활성화 상태일 때 채워진 아이콘 반환
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.location_on_outlined:
return Icons.location_on;
case Icons.business_outlined:
return Icons.business;
case Icons.people_outlined:
return Icons.people;
case Icons.support_outlined:
return Icons.support;
case Icons.build_circle_outlined:
return Icons.build_circle;
case Icons.bug_report_outlined:
return Icons.bug_report;
case Icons.analytics_outlined:
return Icons.analytics;
case Icons.factory_outlined:
return Icons.factory;
case Icons.category_outlined:
return Icons.category;
case Icons.calendar_month_outlined:
return Icons.calendar_month;
default:
return outlinedIcon;
}
}
}