import 'dart:math' as math; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:get_it/get_it.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../../../core/constants/app_sections.dart'; import '../../../../core/network/api_error.dart'; import '../../../../core/network/failure.dart'; import '../../../../core/permissions/permission_manager.dart'; import '../../../auth/application/auth_service.dart'; import '../../../auth/domain/entities/auth_session.dart'; import '../../../auth/domain/entities/login_request.dart'; import '../../../masters/group/domain/entities/group.dart'; import '../../../masters/group/domain/repositories/group_repository.dart'; import '../../../masters/group_permission/application/permission_synchronizer.dart'; import '../../../masters/group_permission/domain/repositories/group_permission_repository.dart'; /// Superport 로그인 화면. 간단한 유효성 검증 후 대시보드로 이동한다. class LoginPage extends StatefulWidget { const LoginPage({super.key}); @override State createState() => _LoginPageState(); } /// 로그인 폼의 상태를 관리한다. class _LoginPageState extends State { final idController = TextEditingController(); final passwordController = TextEditingController(); bool rememberMe = false; bool isLoading = false; String? errorMessage; @override void dispose() { idController.dispose(); passwordController.dispose(); super.dispose(); } Future _handleSubmit() async { if (isLoading) return; setState(() { errorMessage = null; isLoading = true; }); final id = idController.text.trim(); final password = passwordController.text.trim(); if (id.isEmpty || password.isEmpty) { setState(() { errorMessage = '아이디와 비밀번호를 모두 입력하세요.'; isLoading = false; }); return; } if (password.length < 6) { setState(() { errorMessage = '비밀번호는 6자 이상이어야 합니다.'; isLoading = false; }); return; } final authService = GetIt.I(); AuthSession? session; if (!mounted) return; try { session = await authService.login( LoginRequest( identifier: id, password: password, rememberMe: rememberMe, ), ); await _applyPermissions(session); } catch (error) { if (!mounted) return; final failure = Failure.from(error); final description = failure.describe(); final hasApiDetails = failure.raw is ApiException; final message = hasApiDetails && description.isNotEmpty ? description : '권한 정보를 불러오지 못했습니다. 잠시 후 다시 시도하세요.'; setState(() { errorMessage = message; isLoading = false; }); return; } if (!mounted) return; setState(() => isLoading = false); context.go(dashboardRoutePath); } @override Widget build(BuildContext context) { final theme = ShadTheme.of(context); return Scaffold( backgroundColor: theme.colorScheme.background, body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 40), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ _buildHeader(theme), const SizedBox(height: 32), ConstrainedBox( constraints: const BoxConstraints(maxWidth: 460), child: _buildLoginCard(theme), ), const SizedBox(height: 24), _buildFooter(theme), ], ), ), ), ), ); } /// 로그인 헤더에 브랜드 아이콘과 안내 문구를 배치한다. Widget _buildHeader(ShadThemeData theme) { return Column( children: [ DecoratedBox( decoration: BoxDecoration( shape: BoxShape.circle, gradient: LinearGradient( colors: [ theme.colorScheme.primary, theme.colorScheme.primary.withValues(alpha: 0.8), theme.colorScheme.primary.withValues(alpha: 0.6), ], begin: Alignment.topLeft, end: Alignment.bottomRight, ), boxShadow: [ BoxShadow( color: theme.colorScheme.primary.withValues(alpha: 0.3), blurRadius: 24, offset: const Offset(0, 12), ), ], ), child: Padding( padding: const EdgeInsets.all(20), child: TweenAnimationBuilder( duration: const Duration(milliseconds: 1400), tween: Tween(begin: 0, end: 1), curve: Curves.easeInOut, builder: (context, value, child) { return Transform.rotate( angle: value * 2 * math.pi * 0.08, child: child, ); }, child: Icon( LucideIcons.ship, size: 48, color: theme.colorScheme.primaryForeground, ), ), ), ), const SizedBox(height: 24), Text( 'Superport v2', style: theme.textTheme.h2.copyWith( foreground: Paint() ..shader = LinearGradient( colors: [ theme.colorScheme.primary, theme.colorScheme.primary.withValues(alpha: 0.7), ], ).createShader(const Rect.fromLTWH(0, 0, 220, 80)), ), ), const SizedBox(height: 8), Text( '포트 운영 전용 스마트 ERP', style: theme.textTheme.muted, textAlign: TextAlign.center, ), ], ); } /// 로그인 폼 카드 레이아웃을 구성한다. Widget _buildLoginCard(ShadThemeData theme) { return ShadCard( title: Text('Superport v2 로그인', style: theme.textTheme.h3), description: Text( '사번 또는 이메일과 비밀번호를 입력하여 대시보드로 이동합니다.', style: theme.textTheme.muted, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ ShadInput( controller: idController, placeholder: const Text('사번 또는 이메일'), autofillHints: const [AutofillHints.username], leading: const Icon(LucideIcons.user), ), const SizedBox(height: 16), ShadInput( controller: passwordController, placeholder: const Text('비밀번호'), obscureText: true, autofillHints: const [AutofillHints.password], leading: const Icon(LucideIcons.lock), ), const SizedBox(height: 12), Row( children: [ ShadSwitch( value: rememberMe, onChanged: (value) => setState(() => rememberMe = value), ), const SizedBox(width: 12), Text('자동 로그인', style: theme.textTheme.small), ], ), const SizedBox(height: 24), if (errorMessage != null) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text( errorMessage!, style: theme.textTheme.small.copyWith( color: theme.colorScheme.destructive, ), ), ), ShadButton( onPressed: isLoading ? null : _handleSubmit, child: Row( mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ if (isLoading) ...[ const SizedBox( width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2), ), const SizedBox(width: 8), ], const Text('로그인'), ], ), ), if (kDebugMode) ...[ const SizedBox(height: 12), // QA 요청: 디버그 로그인 버튼을 테마 색상과 굵은 서체로 강조하여 피드백 반영. ShadButton.ghost( onPressed: isLoading ? null : _handleTeraLogin, child: Text( 'tera로그인', style: theme.textTheme.small.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ), const SizedBox(height: 8), ShadButton.ghost( onPressed: isLoading ? null : _handleExaLogin, child: Text( 'exa로그인', style: theme.textTheme.small.copyWith( color: theme.colorScheme.primary, fontWeight: FontWeight.w600, ), ), ), ], ], ), ); } /// 로그인 하단에 간단한 안내 메시지를 노출한다. Widget _buildFooter(ShadThemeData theme) { return Column( children: [ Row( mainAxisAlignment: MainAxisAlignment.center, children: const [ Icon(LucideIcons.packagePlus, size: 18), SizedBox(width: 12), Icon(LucideIcons.packageMinus, size: 18), SizedBox(width: 12), Icon(LucideIcons.handshake, size: 18), ], ), const SizedBox(height: 16), Text( '입고 · 출고 · 대여 전 과정을 한 곳에서 관리하세요.', style: theme.textTheme.small, textAlign: TextAlign.center, ), ], ); } /// 디버그 모드에서 테라(Tera) 계정으로 실서버 로그인한다. void _handleTeraLogin() { if (isLoading) { return; } const teraIdentifier = 'terabits'; const teraPassword = '123456'; idController.text = teraIdentifier; passwordController.text = teraPassword; setState(() { rememberMe = false; errorMessage = null; }); _handleSubmit(); } /// 디버그 모드에서 엑사(Exa) 계정으로 실서버 로그인한다. void _handleExaLogin() { if (isLoading) { return; } const exaIdentifier = 'exabits'; const exaPassword = '123456'; idController.text = exaIdentifier; passwordController.text = exaPassword; setState(() { rememberMe = false; errorMessage = null; }); _handleSubmit(); } Future _applyPermissions(AuthSession session) async { final manager = PermissionScope.of(context); manager.clearServerPermissions(); final aggregated = >{}; for (final permission in session.permissions) { final map = permission.toPermissionMap(); for (final entry in map.entries) { aggregated .putIfAbsent(entry.key, () => {}) .addAll(entry.value); } } if (aggregated.isNotEmpty) { manager.applyServerPermissions(aggregated); return; } await _synchronizePermissions(groupId: session.user.primaryGroupId); } Future _synchronizePermissions({int? groupId}) async { final manager = PermissionScope.of(context); manager.clearServerPermissions(); final groupRepository = GetIt.I(); int? targetGroupId = groupId; if (targetGroupId == null) { final defaultGroups = await groupRepository.list( page: 1, pageSize: 1, isDefault: true, ); var targetGroup = _firstGroupWithId(defaultGroups.items); if (targetGroup == null) { final fallbackGroups = await groupRepository.list(page: 1, pageSize: 1); targetGroup = _firstGroupWithId(fallbackGroups.items); } targetGroupId = targetGroup?.id; } if (targetGroupId == null) { return; } final permissionRepository = GetIt.I(); final synchronizer = PermissionSynchronizer( repository: permissionRepository, manager: manager, ); await synchronizer.syncForGroup(targetGroupId); } Group? _firstGroupWithId(List groups) { for (final group in groups) { if (group.id != null) { return group; } } return null; } }