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/constants/app_sections.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/user/domain/entities/user.dart'; import '../features/masters/user/domain/repositories/user_repository.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 (_hasPageAccess(manager, page)) 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), 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 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; } bool _hasPageAccess(PermissionManager manager, AppPageDescriptor page) { final requirements = {page.path, ...page.extraRequiredResources}; for (final resource in requirements) { if (resource.isEmpty) { continue; } if (!manager.can(resource, PermissionAction.view)) { return false; } } return true; } /// 계정 정보를 확인하고 로그아웃을 수행하는 상단바 버튼. 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, }; }