import 'dart:async'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart' as lucide; import 'package:shadcn_ui/shadcn_ui.dart'; import '../core/navigation/menu_route_definitions.dart'; import '../core/navigation/route_paths.dart'; import '../core/navigation/menu_catalog.dart'; import '../core/permissions/permission_manager.dart'; import '../core/network/failure.dart'; import '../core/theme/theme_controller.dart'; import '../core/validation/password_rules.dart'; import '../features/auth/application/auth_service.dart'; import '../features/auth/domain/entities/auth_session.dart'; import '../features/masters/menu/domain/entities/menu.dart'; import '../features/masters/user/domain/entities/user.dart'; import '../features/masters/user/domain/repositories/user_repository.dart'; import 'components/superport_dialog.dart'; /// 앱 기본 레이아웃을 제공하는 셸 위젯. 사이드 네비게이션과 AppBar를 구성한다. class AppShell extends StatefulWidget { const AppShell({ super.key, required this.child, required this.currentLocation, }); final Widget child; final String currentLocation; @override State createState() => _AppShellState(); } class _AppShellState extends State { @override Widget build(BuildContext context) { final catalog = MenuCatalogScope.of(context); return AnimatedBuilder( animation: catalog, builder: (context, _) { return LayoutBuilder( builder: (context, constraints) { final isWide = constraints.maxWidth >= 960; final manager = PermissionScope.of(context); final pages = _resolveNavigationItems(manager, catalog.menus); 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: widget.currentLocation, pages: pages, isLoading: catalog.isLoading, errorMessage: catalog.errorMessage, onRetry: () => _handleMenuRefresh(catalog), ), const VerticalDivider(width: 1), Expanded(child: widget.child), ], ), ); } return Scaffold( appBar: appBar, drawer: Drawer( child: SafeArea( child: _NavigationList( currentLocation: widget.currentLocation, onTap: (path) { Navigator.of(context).pop(); context.go(path); }, pages: pages, isLoading: catalog.isLoading, errorMessage: catalog.errorMessage, onRetry: () => _handleMenuRefresh(catalog), ), ), ), body: widget.child, ); }, ); }, ); } List<_NavigationMenuItem> _resolveNavigationItems( PermissionManager manager, List menus, ) { final addedCodes = {}; if (menus.isNotEmpty) { final codeToMenu = {for (final menu in menus) menu.menuCode: menu}; final sortedMenus = [...menus] ..sort((a, b) { final defA = menuRouteDefinitionByCode[a.menuCode]; final defB = menuRouteDefinitionByCode[b.menuCode]; final aliasFlagA = _aliasPriority(a, defA); final aliasFlagB = _aliasPriority(b, defB); if (aliasFlagA != aliasFlagB) { return aliasFlagA - aliasFlagB; } final orderA = a.displayOrder ?? defA?.defaultOrder ?? 0; final orderB = b.displayOrder ?? defB?.defaultOrder ?? 0; return orderA.compareTo(orderB); }); final items = <_NavigationMenuItem>[]; for (final menu in sortedMenus) { if (!menu.isActive || menu.isDeleted) { continue; } final definition = menuRouteDefinitionByCode[menu.menuCode]; if (definition == null || !definition.showInNavigation) { continue; } final canonicalCode = definition.menuCode; if (addedCodes.contains(canonicalCode)) { continue; } if (!definition.canAccess(manager)) { continue; } addedCodes.add(canonicalCode); final parentCode = menu.parent?.menuCode ?? _parentCode(menu.menuCode); final parentOrder = parentCode != null ? codeToMenu[parentCode]?.displayOrder ?? definition.defaultOrder : definition.defaultOrder; final displayOrder = menu.displayOrder ?? definition.defaultOrder; items.add( _NavigationMenuItem( menuCode: canonicalCode, label: menu.menuName, path: definition.routePath, icon: definition.icon, sortOrder: parentOrder * 1000 + displayOrder, ), ); } if (items.isNotEmpty) { items.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); return items; } } final fallbackItems = menuRouteDefinitions .where((definition) => definition.showInNavigation) .where((definition) => definition.canAccess(manager)) .map( (definition) => _NavigationMenuItem( menuCode: definition.menuCode, label: definition.defaultLabel, path: definition.routePath, icon: definition.icon, sortOrder: definition.defaultOrder, ), ) .toList(); fallbackItems.sort((a, b) => a.sortOrder.compareTo(b.sortOrder)); return fallbackItems; } void _handleMenuRefresh(MenuCatalog catalog) { unawaited( catalog.refresh().catchError((error, stackTrace) { final failure = Failure.from(error); debugPrint('메뉴 갱신 실패: ${failure.describe()}'); }), ); } int _aliasPriority(MenuItem menu, MenuRouteDefinition? definition) { if (definition == null) { return 2; } return definition.menuCode == menu.menuCode ? 0 : 1; } String? _parentCode(String code) { final separatorIndex = code.lastIndexOf('.'); if (separatorIndex == -1) { return null; } return code.substring(0, separatorIndex); } } class _NavigationMenuItem { const _NavigationMenuItem({ required this.menuCode, required this.label, required this.path, required this.icon, required this.sortOrder, }); final String menuCode; final String label; final String path; final IconData icon; final int sortOrder; } class _NavigationRail extends StatelessWidget { const _NavigationRail({ required this.currentLocation, required this.pages, required this.isLoading, required this.errorMessage, required this.onRetry, }); final String currentLocation; final List<_NavigationMenuItem> pages; final bool isLoading; final String? errorMessage; final VoidCallback onRetry; @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: [ if (isLoading) const LinearProgressIndicator(minHeight: 2), if (errorMessage != null) _NavigationErrorBanner(message: errorMessage!, onRetry: onRetry), 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, required this.isLoading, required this.errorMessage, required this.onRetry, }); final String currentLocation; final ValueChanged onTap; final List<_NavigationMenuItem> pages; final bool isLoading; final String? errorMessage; final VoidCallback onRetry; @override Widget build(BuildContext context) { final selectedIndex = _selectedIndex(currentLocation, pages); final themeController = ThemeControllerScope.of(context); return ListView.builder( itemCount: pages.length + 2, itemBuilder: (context, index) { if (index == 0) { return Column( children: [ if (isLoading) const LinearProgressIndicator(minHeight: 2), if (errorMessage != null) _NavigationErrorBanner( message: errorMessage!, onRetry: onRetry, ), ], ); } if (index == pages.length + 1) { 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 - 1]; final selected = (index - 1) == selectedIndex; return ListTile( leading: Icon(page.icon), title: Text(page.label), selected: selected, selectedColor: Theme.of(context).colorScheme.primary, onTap: () => onTap(page.path), ); }, ); } } class _NavigationErrorBanner extends StatelessWidget { const _NavigationErrorBanner({required this.message, required this.onRetry}); final String message; final VoidCallback onRetry; @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: DecoratedBox( decoration: BoxDecoration( color: colorScheme.errorContainer.withValues(alpha: 0.6), borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(12), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(lucide.LucideIcons.info, size: 18, color: colorScheme.error), const SizedBox(width: 8), Expanded( child: Text( message, style: theme.textTheme.labelSmall?.copyWith( color: colorScheme.onErrorContainer, ), maxLines: 3, overflow: TextOverflow.ellipsis, ), ), TextButton(onPressed: onRetry, child: const Text('재시도')), ], ), ), ), ); } } 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), Flexible( child: Text( 'Superport v2', maxLines: 1, overflow: TextOverflow.ellipsis, 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<_NavigationMenuItem> 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, _) { return IconButton( tooltip: '내 정보', icon: const Icon(lucide.LucideIcons.userRound), onPressed: () => _handlePressed(context), ); }, ); } Future _handlePressed(BuildContext context) async { final userRepository = GetIt.I(); final result = await showDialog<_AccountDialogResult>( context: context, barrierDismissible: false, builder: (_) => _AccountDialog( authService: service, userRepository: userRepository, hostContext: context, ), ); if (!context.mounted) { return; } switch (result) { case _AccountDialogResult.logout: await service.clearSession(); if (context.mounted) { context.go(loginRoutePath); } break; case _AccountDialogResult.passwordChanged: final confirmed = await _showMandatoryLogoutDialog(context); if (!context.mounted) { break; } if (confirmed == true) { await service.clearSession(); if (context.mounted) { context.go(loginRoutePath); } } break; case _AccountDialogResult.none: case null: break; } } Future _showMandatoryLogoutDialog(BuildContext context) { return SuperportDialog.show( context: context, barrierDismissible: false, dialog: SuperportDialog( title: '비밀번호 변경 완료', description: '비밀번호가 변경되었습니다. 다시 로그인해주세요.', showCloseButton: false, primaryAction: ShadButton( onPressed: () => Navigator.of(context, rootNavigator: true).pop(true), child: const Text('확인'), ), ), ); } } enum _AccountDialogResult { none, logout, passwordChanged } class _AccountDialog extends StatefulWidget { const _AccountDialog({ required this.authService, required this.userRepository, required this.hostContext, }); final AuthService authService; final UserRepository userRepository; final BuildContext hostContext; @override State<_AccountDialog> createState() => _AccountDialogState(); } class _AccountDialogState extends State<_AccountDialog> { static final RegExp _emailRegExp = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$'); static final RegExp _phoneRegExp = RegExp(r'^[0-9+\-\s]{7,}$'); late final TextEditingController _emailController; late final TextEditingController _phoneController; String? _emailError; String? _phoneError; String? _generalError; bool _isSaving = false; late String _initialEmail; late String _initialPhone; AuthSession? get _session => widget.authService.session; bool get _isDirty => _emailController.text.trim() != _initialEmail || _phoneController.text.trim() != _initialPhone; bool get _canEdit => _session != null; @override void initState() { super.initState(); final session = _session; _initialEmail = session?.user.email ?? ''; _initialPhone = session?.user.phone ?? ''; _emailController = TextEditingController(text: _initialEmail) ..addListener(_handleChanged); _phoneController = TextEditingController(text: _initialPhone) ..addListener(_handleChanged); } @override void dispose() { _emailController.dispose(); _phoneController.dispose(); super.dispose(); } void _handleChanged() { if ((_emailError != null || _phoneError != null) && mounted) { setState(() { _emailError = null; _phoneError = null; _generalError = null; }); } else { setState(() {}); } } @override Widget build(BuildContext context) { final session = _session; return _wrapWithWillPop( SuperportDialog( title: '내 정보', description: session == null ? '로그인 정보를 찾을 수 없습니다.' : '${session.user.name}님의 계정 정보를 확인하고 수정하세요.', scrollable: true, showCloseButton: false, footer: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 20), child: Row( mainAxisAlignment: MainAxisAlignment.end, children: [ ShadButton.outline( onPressed: _isSaving ? null : () => Navigator.of( context, rootNavigator: true, ).pop(_AccountDialogResult.none), child: const Text('닫기'), ), const SizedBox(width: 12), ShadButton.destructive( onPressed: !_canEdit || _isSaving ? null : () => Navigator.of( context, rootNavigator: true, ).pop(_AccountDialogResult.logout), child: const Text('로그아웃'), ), ], ), ), child: _AccountDialogBody( session: session, emailController: _emailController, phoneController: _phoneController, emailError: _emailError, phoneError: _phoneError, generalError: _generalError, isSaving: _isSaving, canEdit: _canEdit, onSave: _saveProfile, onPasswordChange: _handlePasswordChange, canSave: _isDirty && !_isSaving && _canEdit, ), ), ); } Widget _wrapWithWillPop(Widget child) { return PopScope(canPop: !_isSaving, child: child); } Future _saveProfile() async { if (!_canEdit || _isSaving) { return; } final email = _emailController.text.trim(); final phone = _phoneController.text.trim(); var hasError = false; if (email.isEmpty || !_emailRegExp.hasMatch(email)) { _emailError = '올바른 이메일 주소를 입력하세요.'; hasError = true; } if (phone.isEmpty || !_phoneRegExp.hasMatch(phone)) { _phoneError = '연락처는 숫자/+, -/공백만 사용해 7자 이상 입력하세요.'; hasError = true; } if (hasError) { setState(() {}); return; } setState(() { _isSaving = true; _generalError = null; }); try { final result = await widget.userRepository.updateMe( UserProfileUpdateInput(email: email, phone: phone), ); final updatedEmail = result.email ?? email; final updatedPhone = result.mobileNo ?? phone; _initialEmail = updatedEmail; _initialPhone = updatedPhone; if (_emailController.text != updatedEmail) { _emailController.text = updatedEmail; } if (_phoneController.text != updatedPhone) { _phoneController.text = updatedPhone; } final session = _session; if (session != null) { final updatedUser = session.user.copyWith( email: updatedEmail, phone: updatedPhone, name: result.employeeName, employeeNo: result.employeeNo, ); widget.authService.updateSessionUser(updatedUser); } setState(() { _isSaving = false; _emailError = null; _phoneError = null; }); _showSnack('프로필 정보를 저장했습니다.'); } catch (error) { final failure = Failure.from(error); setState(() { _isSaving = false; _generalError = failure.describe(); }); } } Future _handlePasswordChange() async { if (!_canEdit || _isSaving) { return; } final changed = await _PasswordChangeDialog.show( context: context, userRepository: widget.userRepository, ); if (changed == true && mounted) { Navigator.of( context, rootNavigator: true, ).pop(_AccountDialogResult.passwordChanged); } } void _showSnack(String message) { final messenger = ScaffoldMessenger.maybeOf(widget.hostContext); messenger?.showSnackBar(SnackBar(content: Text(message))); } } class _AccountDialogBody extends StatelessWidget { const _AccountDialogBody({ required this.session, required this.emailController, required this.phoneController, required this.emailError, required this.phoneError, required this.generalError, required this.isSaving, required this.canEdit, required this.onSave, required this.onPasswordChange, required this.canSave, }); final AuthSession? session; final TextEditingController emailController; final TextEditingController phoneController; final String? emailError; final String? phoneError; final String? generalError; final bool isSaving; final bool canEdit; final bool canSave; final VoidCallback onSave; final VoidCallback onPasswordChange; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _AccountInfoContent(session: session), const SizedBox(height: 24), Divider(color: Theme.of(context).colorScheme.outlineVariant), const SizedBox(height: 16), Text('연락처 / 이메일 수정', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 12), _LabeledField( label: '이메일', controller: emailController, fieldKey: const ValueKey('account_email_field'), enabled: canEdit && !isSaving, keyboardType: TextInputType.emailAddress, errorText: emailError, ), const SizedBox(height: 12), _LabeledField( label: '연락처', controller: phoneController, fieldKey: const ValueKey('account_phone_field'), enabled: canEdit && !isSaving, keyboardType: TextInputType.phone, errorText: phoneError, ), if (generalError != null) ...[ const SizedBox(height: 12), Text( generalError!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.error, ), ), ], const SizedBox(height: 16), Align( alignment: Alignment.centerRight, child: ShadButton( onPressed: canSave ? onSave : null, child: isSaving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('저장'), ), ), const SizedBox(height: 24), Divider(color: Theme.of(context).colorScheme.outlineVariant), const SizedBox(height: 16), Text('보안', style: Theme.of(context).textTheme.titleSmall), const SizedBox(height: 12), ShadButton.outline( onPressed: isSaving ? null : onPasswordChange, child: const Text('비밀번호 변경'), ), ], ); } } class _LabeledField extends StatelessWidget { const _LabeledField({ required this.label, required this.controller, this.fieldKey, this.enabled = true, this.keyboardType, this.errorText, }); final String label; final TextEditingController controller; final Key? fieldKey; final bool enabled; final TextInputType? keyboardType; final String? errorText; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final materialTheme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: theme.textTheme.small), const SizedBox(height: 6), ShadInput( key: fieldKey, controller: controller, enabled: enabled, keyboardType: keyboardType, ), if (errorText != null) ...[ const SizedBox(height: 6), Text( errorText!, style: theme.textTheme.small.copyWith( color: materialTheme.colorScheme.error, ), ), ], ], ); } } class _PasswordChangeDialog extends StatefulWidget { const _PasswordChangeDialog({required this.userRepository}); final UserRepository userRepository; static Future show({ required BuildContext context, required UserRepository userRepository, }) { return showDialog( context: context, barrierDismissible: false, builder: (_) => _PasswordChangeDialog(userRepository: userRepository), ); } @override State<_PasswordChangeDialog> createState() => _PasswordChangeDialogState(); } class _PasswordChangeDialogState extends State<_PasswordChangeDialog> { final TextEditingController _currentController = TextEditingController(); final TextEditingController _newController = TextEditingController(); final TextEditingController _confirmController = TextEditingController(); String? _currentError; String? _newError; String? _confirmError; String? _generalError; bool _isSaving = false; @override void dispose() { _currentController.dispose(); _newController.dispose(); _confirmController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return SuperportDialog( title: '비밀번호 변경', showCloseButton: !_isSaving, primaryAction: ShadButton( onPressed: _isSaving ? null : _handleSubmit, child: _isSaving ? const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ) : const Text('변경'), ), secondaryAction: ShadButton.outline( onPressed: _isSaving ? null : () => Navigator.of(context, rootNavigator: true).pop(false), child: const Text('취소'), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ _PasswordField( label: '현재 비밀번호', controller: _currentController, fieldKey: const ValueKey('account_current_password'), errorText: _currentError, enabled: !_isSaving, ), const SizedBox(height: 12), _PasswordField( label: '새 비밀번호', controller: _newController, fieldKey: const ValueKey('account_new_password'), errorText: _newError, enabled: !_isSaving, helper: '8~24자, 대문자/소문자/숫자/특수문자 각 1자 이상 포함', ), const SizedBox(height: 12), _PasswordField( label: '새 비밀번호 확인', controller: _confirmController, fieldKey: const ValueKey('account_confirm_password'), errorText: _confirmError, enabled: !_isSaving, ), if (_generalError != null) ...[ const SizedBox(height: 12), Text( _generalError!, style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of(context).colorScheme.error, ), ), ], ], ), ); } Future _handleSubmit() async { if (_isSaving) return; final current = _currentController.text.trim(); final next = _newController.text.trim(); final confirm = _confirmController.text.trim(); var hasError = false; if (current.isEmpty) { _currentError = '현재 비밀번호를 입력하세요.'; hasError = true; } else { _currentError = null; } if (!PasswordRules.isValid(next)) { _newError = '비밀번호 정책을 만족하도록 입력하세요.'; hasError = true; } else { _newError = null; } if (next != confirm) { _confirmError = '새 비밀번호가 일치하지 않습니다.'; hasError = true; } else { _confirmError = null; } if (hasError) { setState(() {}); return; } setState(() { _isSaving = true; _generalError = null; }); try { await widget.userRepository.updateMe( UserProfileUpdateInput(password: next, currentPassword: current), ); if (mounted) { Navigator.of(context, rootNavigator: true).pop(true); } } catch (error) { final failure = Failure.from(error); setState(() { _generalError = failure.describe(); _isSaving = false; }); } } } class _PasswordField extends StatelessWidget { const _PasswordField({ required this.label, required this.controller, this.helper, this.errorText, this.enabled = true, this.fieldKey, }); final String label; final TextEditingController controller; final String? helper; final String? errorText; final bool enabled; final Key? fieldKey; @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); final materialTheme = Theme.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: theme.textTheme.small), const SizedBox(height: 6), ShadInput( key: fieldKey, controller: controller, enabled: enabled, obscureText: true, ), if (helper != null) Padding( padding: const EdgeInsets.only(top: 6), child: Text(helper!, style: theme.textTheme.muted), ), if (errorText != null) Padding( padding: const EdgeInsets.only(top: 6), child: Text( errorText!, style: theme.textTheme.small.copyWith( color: materialTheme.colorScheme.error, ), ), ), ], ); } } /// 로그인된 계정의 핵심 정보를 보여주는 다이얼로그 본문. 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.phone ?? '-'), _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, }; }