import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import '../core/constants/app_sections.dart'; import '../core/permissions/permission_manager.dart'; import '../core/theme/theme_controller.dart'; import '../features/auth/application/auth_service.dart'; import '../features/auth/domain/entities/auth_session.dart'; import 'components/superport_dialog.dart'; /// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다. class AppShell extends StatelessWidget { const AppShell({ super.key, required this.child, required this.currentLocation, }); final Widget child; final String currentLocation; @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 960; final manager = PermissionScope.of(context); final filteredPages = [ for (final section in appSections) for (final page in section.pages) if (manager.can(page.path, PermissionAction.view)) page, ]; final pages = filteredPages.isEmpty ? allAppPages : filteredPages; final themeController = ThemeControllerScope.of(context); final authService = GetIt.I(); final appBar = _GradientAppBar( title: const _BrandTitle(), actions: [ _ThemeMenuButton( mode: themeController.mode, onChanged: themeController.update, ), const SizedBox(width: 8), _AccountMenuButton(service: authService), const SizedBox(width: 8), ], ); if (isWide) { return Scaffold( appBar: appBar, body: Row( children: [ _NavigationRail(currentLocation: currentLocation, pages: pages), const VerticalDivider(width: 1), Expanded(child: child), ], ), ); } return Scaffold( appBar: appBar, drawer: Drawer( child: SafeArea( child: _NavigationList( currentLocation: currentLocation, onTap: (path) { Navigator.of(context).pop(); context.go(path); }, pages: pages, ), ), ), body: child, ); }, ); } } class _NavigationRail extends StatelessWidget { const _NavigationRail({required this.currentLocation, required this.pages}); final String currentLocation; final List pages; @override Widget build(BuildContext context) { final selectedIndex = _selectedIndex(currentLocation, pages); final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Container( width: 220, decoration: BoxDecoration( border: Border(right: BorderSide(color: colorScheme.outlineVariant)), ), child: Column( children: [ const SizedBox(height: 24), Expanded( child: ListView.builder( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: pages.length, itemBuilder: (context, index) { final page = pages[index]; final isSelected = index == selectedIndex; final textStyle = theme.textTheme.labelSmall?.copyWith( color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, ); return Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Material( color: isSelected ? colorScheme.primary.withValues(alpha: 0.12) : Colors.transparent, borderRadius: BorderRadius.circular(12), child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () { if (page.path != currentLocation) { context.go(page.path); } }, child: Padding( padding: const EdgeInsets.symmetric( vertical: 10, horizontal: 12, ), child: Row( children: [ Icon( page.icon, size: 22, color: isSelected ? colorScheme.primary : colorScheme.onSurfaceVariant, ), const SizedBox(width: 12), Expanded( child: Text( page.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: textStyle, ), ), ], ), ), ), ), ); }, ), ), ], ), ); } } class _NavigationList extends StatelessWidget { const _NavigationList({ required this.currentLocation, required this.onTap, required this.pages, }); final String currentLocation; final ValueChanged onTap; final List pages; @override Widget build(BuildContext context) { final selectedIndex = _selectedIndex(currentLocation, pages); final themeController = ThemeControllerScope.of(context); return ListView.builder( itemCount: pages.length + 1, itemBuilder: (context, index) { if (index == pages.length) { return Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 16), child: _ThemeMenuButton( mode: themeController.mode, onChanged: (mode) { themeController.update(mode); Navigator.of(context).maybePop(); }, ), ); } final page = pages[index]; final selected = index == selectedIndex; return ListTile( leading: Icon(page.icon), title: Text(page.label), subtitle: Text( page.summary, maxLines: 1, overflow: TextOverflow.ellipsis, ), selected: selected, selectedColor: Theme.of(context).colorScheme.primary, onTap: () => onTap(page.path), ); }, ); } } class _BrandTitle extends StatelessWidget { const _BrandTitle(); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Row( mainAxisSize: MainAxisSize.min, children: [ DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, color: colorScheme.primary, ), child: Padding( padding: const EdgeInsets.all(12), child: Icon( lucide.LucideIcons.ship, size: 28, color: colorScheme.onPrimary, ), ), ), const SizedBox(width: 12), Text( 'Superport v2', style: theme.textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w600, ), ), ], ); } } class _GradientAppBar extends StatelessWidget implements PreferredSizeWidget { const _GradientAppBar({required this.title, required this.actions}); final Widget title; final List actions; @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return AppBar( backgroundColor: Colors.transparent, surfaceTintColor: Colors.transparent, automaticallyImplyLeading: false, titleSpacing: 16, toolbarHeight: kToolbarHeight, title: title, actions: actions, flexibleSpace: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.bottomCenter, end: Alignment.topCenter, colors: [ colorScheme.primary.withValues(alpha: 0), colorScheme.primary.withValues(alpha: 0.2), ], stops: const [0, 1], ), ), ), ); } } class _ThemeMenuButton extends StatelessWidget { const _ThemeMenuButton({required this.mode, required this.onChanged}); final ThemeMode mode; final ValueChanged onChanged; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; final label = _label(mode); final icon = _icon(mode); return PopupMenuButton( tooltip: '테마 변경', onSelected: onChanged, itemBuilder: (context) => ThemeMode.values .map( (value) => PopupMenuItem( value: value, child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon(_icon(value), size: 18), const SizedBox(width: 8), Flexible( child: Text( _label(value), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ), ) .toList(), child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( borderRadius: BorderRadius.circular(12), border: Border.all(color: colorScheme.outlineVariant), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 18), const SizedBox(width: 8), Flexible( child: Text( '테마 · $label', style: theme.textTheme.labelSmall, maxLines: 1, overflow: TextOverflow.ellipsis, ), ), ], ), ), ); } static String _label(ThemeMode mode) { switch (mode) { case ThemeMode.system: return '시스템'; case ThemeMode.light: return '라이트'; case ThemeMode.dark: return '다크'; } } static IconData _icon(ThemeMode mode) { switch (mode) { case ThemeMode.system: return lucide.LucideIcons.monitorCog; case ThemeMode.light: return lucide.LucideIcons.sun; case ThemeMode.dark: return lucide.LucideIcons.moon; } } } int _selectedIndex(String location, List pages) { final normalized = location.toLowerCase(); final exact = pages.indexWhere( (page) => normalized == page.path.toLowerCase(), ); if (exact != -1) { return exact; } final prefix = pages.indexWhere( (page) => normalized.startsWith(page.path.toLowerCase()), ); return prefix == -1 ? 0 : prefix; } /// 계정 정보를 확인하고 로그아웃을 수행하는 상단바 버튼. class _AccountMenuButton extends StatelessWidget { const _AccountMenuButton({required this.service}); final AuthService service; @override Widget build(BuildContext context) { return AnimatedBuilder( animation: service, builder: (context, _) { final session = service.session; return IconButton( tooltip: '계정 정보', icon: const Icon(lucide.LucideIcons.userRound), onPressed: () => _handlePressed(context, session), ); }, ); } Future _handlePressed( BuildContext context, AuthSession? session, ) async { final shouldLogout = await SuperportDialog.show( context: context, dialog: SuperportDialog( title: '계정 정보', description: session == null ? '로그인 정보를 찾을 수 없습니다.' : '현재 로그인된 계정 세부 정보를 확인하세요.', child: _AccountInfoContent(session: session), footer: Builder( builder: (dialogContext) { return Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ ShadButton.outline( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('닫기'), ), const SizedBox(width: 12), ShadButton.destructive( onPressed: session == null ? null : () => Navigator.of(dialogContext).pop(true), child: const Text('로그아웃'), ), ], ), ); }, ), scrollable: session != null && session.permissions.length > 6, ), ); if (shouldLogout == true) { await service.clearSession(); if (!context.mounted) return; context.go(loginRoutePath); } } } /// 로그인된 계정의 핵심 정보를 보여주는 다이얼로그 본문. class _AccountInfoContent extends StatelessWidget { const _AccountInfoContent({required this.session}); final AuthSession? session; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; if (session == null) { return Padding( padding: const EdgeInsets.symmetric(vertical: 24), child: Text( '로그인된 사용자 정보를 불러오지 못했습니다. 다시 로그인하면 세션이 갱신됩니다.', style: theme.textTheme.bodyMedium?.copyWith(color: colorScheme.error), ), ); } final user = session!.user; final expiryLabel = _formatExpiry(session!.expiresAt); final permissionSummaries = _PermissionSummary.build(session!); return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _AccountInfoRow(label: '이름', value: user.name), _AccountInfoRow(label: '사번', value: user.employeeNo ?? '-'), _AccountInfoRow(label: '이메일', value: user.email ?? '-'), _AccountInfoRow(label: '기본 그룹', value: user.primaryGroupName ?? '-'), _AccountInfoRow(label: '토큰 만료', value: expiryLabel), _AccountInfoRow( label: '권한 리소스', value: '${permissionSummaries.length}개', ), if (permissionSummaries.isNotEmpty) ...[ const SizedBox(height: 16), Text( '권한 요약', style: theme.textTheme.titleSmall?.copyWith( fontWeight: FontWeight.w600, ), ), const SizedBox(height: 8), Wrap( spacing: 8, runSpacing: 8, children: [ for (final summary in permissionSummaries.take(6)) ShadBadge.outline( child: Text( '${summary.resource} · ${_formatActions(summary.actions)}', ), ), if (permissionSummaries.length > 6) ShadBadge.outline( child: Text('외 ${permissionSummaries.length - 6}개 리소스'), ), ], ), ], ], ); } String _formatExpiry(DateTime? expiresAt) { if (expiresAt == null) { return '만료 정보 없음'; } final formatter = DateFormat('yyyy-MM-dd HH:mm'); return formatter.format(expiresAt.toLocal()); } } /// 다이얼로그에서 라벨과 값을 한 줄로 표시한다. class _AccountInfoRow extends StatelessWidget { const _AccountInfoRow({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Padding( padding: const EdgeInsets.symmetric(vertical: 6), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 112, child: Text( label, style: theme.textTheme.bodySmall?.copyWith( fontWeight: FontWeight.w600, color: colorScheme.onSurfaceVariant, ), ), ), Expanded( child: Text( value.isEmpty ? '-' : value, style: theme.textTheme.bodyMedium, ), ), ], ), ); } } /// 리소스별 권한 액션 목록 요약 데이터. class _PermissionSummary { const _PermissionSummary({required this.resource, required this.actions}); final String resource; final List actions; static List<_PermissionSummary> build(AuthSession session) { final Map> aggregated = {}; for (final permission in session.permissions) { final bucket = aggregated.putIfAbsent( permission.resource, () => {}, ); for (final raw in permission.actions) { final normalized = raw.trim().toLowerCase(); if (normalized.isEmpty) continue; bucket.add(normalized); } } final summaries = aggregated.entries .map( (entry) => _PermissionSummary( resource: entry.key, actions: entry.value.toList()..sort(), ), ) .toList() ..sort((a, b) => a.resource.compareTo(b.resource)); return summaries; } } String _formatActions(List actions) { if (actions.isEmpty) { return '권한 없음'; } final labels = actions.map(_koreanActionLabel).toList()..sort(); return labels.join(', '); } String _koreanActionLabel(String action) { return switch (action.trim().toLowerCase()) { 'view' => '조회', 'read' => '조회', 'create' => '생성', 'edit' => '수정', 'update' => '수정', 'delete' => '삭제', 'restore' => '복구', 'approve' => '결재', _ => action, }; }