From dec66c136ca28c003023c2174b6ad3bd6976e1fb Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 26 Jan 2026 17:22:55 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B0=80=EC=A1=B1=EC=82=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=A9=94=EC=9D=BC=20=EC=9E=90=EB=8F=99=20=EB=A7=A4=ED=95=91?= =?UTF-8?q?=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/presentation/signup_screen.dart | 290 +++++++++++++----- 1 file changed, 219 insertions(+), 71 deletions(-) diff --git a/frontend/lib/features/auth/presentation/signup_screen.dart b/frontend/lib/features/auth/presentation/signup_screen.dart index 02c9e65e..8f491552 100644 --- a/frontend/lib/features/auth/presentation/signup_screen.dart +++ b/frontend/lib/features/auth/presentation/signup_screen.dart @@ -14,7 +14,7 @@ class SignupScreen extends StatefulWidget { class _SignupScreenState extends State { final _formKey = GlobalKey(); - int _currentStep = 1; // 1: 인증, 2: 정보, 3: 비밀번호 + int _currentStep = 1; // Controllers final _emailController = TextEditingController(); @@ -31,6 +31,7 @@ class _SignupScreenState extends State { bool _isPhoneVerified = false; String _affiliationType = 'GENERAL'; String? _companyCode; + bool _isAffiliateEmail = false; // 가족사 이메일 여부 bool _termsAccepted = false; bool _privacyAccepted = false; bool _isLoading = false; @@ -47,6 +48,16 @@ class _SignupScreenState extends State { Timer? _phoneTimer; int _phoneSeconds = 0; + // 가족사 도메인 맵 + final Map _affiliateDomains = { + 'hanmaceng.co.kr': 'HANMAC', + 'samaneng.com': 'SAMAN', + 'jangheon.co.kr': 'JANGHEON', + 'hallasanup.com': 'HALLA', + 'pre-cast.co.kr': 'PTC', + 'baroncs.co.kr': 'BARON', + }; + @override void dispose() { _emailTimer?.cancel(); @@ -62,7 +73,36 @@ class _SignupScreenState extends State { super.dispose(); } - // --- Logic Methods --- + // 이메일 입력 시 도메인 체크 로직 + void _checkEmailAffiliation(String email) { + if (!email.contains('@')) { + if (_isAffiliateEmail) { + setState(() { + _isAffiliateEmail = false; + _affiliationType = 'GENERAL'; + _companyCode = null; + }); + } + return; + } + + final domain = email.split('@').last.toLowerCase(); + if (_affiliateDomains.containsKey(domain)) { + setState(() { + _isAffiliateEmail = true; + _affiliationType = 'AFFILIATE'; + _companyCode = _affiliateDomains[domain]; + }); + } else { + if (_isAffiliateEmail) { + setState(() { + _isAffiliateEmail = false; + _affiliationType = 'GENERAL'; + _companyCode = null; + }); + } + } + } void _startTimer(String type) { if (type == 'email') { @@ -101,7 +141,6 @@ class _SignupScreenState extends State { setState(() => _emailError = '유효한 이메일 형식이 아닙니다.'); return; } - setState(() { _isLoading = true; _emailError = null; }); try { final available = await AuthProxyService.checkEmailAvailability(email); @@ -178,7 +217,6 @@ class _SignupScreenState extends State { return; } if (!_formKey.currentState!.validate()) return; - if (!_termsAccepted || !_privacyAccepted) return; setState(() { _isLoading = true; @@ -231,11 +269,13 @@ class _SignupScreenState extends State { padding: const EdgeInsets.symmetric(vertical: 20), child: Row( children: [ - _stepCircle(1, '본인인증'), + _stepCircle(1, '약관동의'), _stepLine(1), - _stepCircle(2, '정보입력'), + _stepCircle(2, '본인인증'), _stepLine(2), - _stepCircle(3, '비밀번호'), + _stepCircle(3, '정보입력'), + _stepLine(3), + _stepCircle(4, '비밀번호'), ], ), ); @@ -247,12 +287,12 @@ class _SignupScreenState extends State { return Column( children: [ CircleAvatar( - radius: 15, + radius: 12, backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]), - child: isDone ? const Icon(Icons.check, size: 16, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 12)), + child: isDone ? const Icon(Icons.check, size: 14, color: Colors.white) : Text('$step', style: TextStyle(color: isCurrent ? Colors.white : Colors.black54, fontSize: 10)), ), const SizedBox(height: 4), - Text(label, style: TextStyle(fontSize: 10, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)), + Text(label, style: TextStyle(fontSize: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)), ], ); } @@ -260,25 +300,92 @@ class _SignupScreenState extends State { Widget _stepLine(int afterStep) { return Expanded( child: Container( - margin: const EdgeInsets.only(bottom: 16, left: 4, right: 4), - height: 2, + margin: const EdgeInsets.only(bottom: 16, left: 2, right: 2), + height: 1.5, color: _currentStep > afterStep ? Colors.green : Colors.grey[300], ), ); } - Widget _buildStep1() { + Widget _buildStepAgreement() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('이메일 인증', style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), + Text('서비스 이용을 위해\n약관에 동의해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold, height: 1.3)), + const SizedBox(height: 32), + _agreementTile( + title: '이용약관 동의 (필수)', + value: _termsAccepted, + onChanged: (val) => setState(() => _termsAccepted = val!), + ), + const Divider(), + _agreementTile( + title: '개인정보 수집 및 이용 동의 (필수)', + value: _privacyAccepted, + onChanged: (val) => setState(() => _privacyAccepted = val!), + ), + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(8)), + child: const Text( + 'Baron SSO는 통합 인증 서비스로, 회원님의 개인정보를 안전하게 보호하며 서비스 제공을 위해 필요한 최소한의 정보만을 수집합니다.', + style: TextStyle(fontSize: 12, color: Colors.grey, height: 1.5), + ), + ), + ], + ); + } + + Widget _agreementTile({required String title, required bool value, required ValueChanged onChanged}) { + return CheckboxListTile( + title: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + value: value, + onChanged: onChanged, + controlAffinity: ListTileControlAffinity.leading, + contentPadding: EdgeInsets.zero, + activeColor: Colors.black, + ); + } + + Widget _buildStepAuth() { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('본인 확인을 위해\n인증을 진행해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 16), + // 가족사 이메일 안내 문구 + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)), + child: const Row( + children: [ + Icon(Icons.info_outline, size: 16, color: Colors.blue), + SizedBox(width: 8), + Expanded( + child: Text( + '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.', + style: TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + const SizedBox(height: 24), + Text('이메일 인증', style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), Row( children: [ Expanded( child: TextFormField( controller: _emailController, - decoration: InputDecoration(labelText: '이메일 주소', border: const OutlineInputBorder(), errorText: _emailError), + onChanged: _checkEmailAffiliation, // 도메인 실시간 체크 + decoration: InputDecoration( + labelText: '이메일 주소', + border: const OutlineInputBorder(), + errorText: _emailError, + hintText: 'example@hanmaceng.co.kr', + ), readOnly: _isEmailVerified, ), ), @@ -287,6 +394,7 @@ class _SignupScreenState extends State { height: 55, child: ElevatedButton( onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode, + style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), child: Text(_emailSeconds > 0 ? '재발송' : '인증요청'), ), ), @@ -297,7 +405,7 @@ class _SignupScreenState extends State { TextFormField( controller: _emailCodeController, decoration: InputDecoration( - labelText: '이메일 인증코드 6자리', + labelText: '인증코드 6자리', suffixText: _formatTime(_emailSeconds), border: const OutlineInputBorder(), ), @@ -308,11 +416,11 @@ class _SignupScreenState extends State { ], if (_isEmailVerified) const Padding( padding: EdgeInsets.only(top: 8), - child: Text('✅ 이메일 인증이 완료되었습니다.', style: TextStyle(color: Colors.green, fontSize: 13)), + child: Text('✅ 이메일 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)), ), const SizedBox(height: 24), - Text('휴대폰 인증', style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 12), + Text('휴대폰 인증', style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 8), Row( children: [ Expanded( @@ -328,6 +436,7 @@ class _SignupScreenState extends State { height: 55, child: ElevatedButton( onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode, + style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0), child: Text(_phoneSeconds > 0 ? '재발송' : '인증요청'), ), ), @@ -338,7 +447,7 @@ class _SignupScreenState extends State { TextFormField( controller: _phoneCodeController, decoration: InputDecoration( - labelText: '휴대폰 인증코드 6자리', + labelText: '인증코드 6자리', suffixText: _formatTime(_phoneSeconds), border: const OutlineInputBorder(), ), @@ -349,51 +458,71 @@ class _SignupScreenState extends State { ], if (_isPhoneVerified) const Padding( padding: EdgeInsets.only(top: 8), - child: Text('✅ 휴대폰 인증이 완료되었습니다.', style: TextStyle(color: Colors.green, fontSize: 13)), + child: Text('✅ 휴대폰 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)), ), ], ); } - Widget _buildStep2() { + Widget _buildStepInfo() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('사용자 정보를 입력해주세요', style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 16), + Text('회원님의\n소속 정보를 알려주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)), + const SizedBox(height: 24), TextFormField( controller: _nameController, + onChanged: (_) => setState(() {}), decoration: const InputDecoration(labelText: '이름', border: OutlineInputBorder()), ), const SizedBox(height: 16), - DropdownButtonFormField( - value: _affiliationType, - decoration: const InputDecoration(labelText: '소속 유형', border: OutlineInputBorder()), - items: const [ - DropdownMenuItem(value: 'GENERAL', child: Text('일반 사용자')), - DropdownMenuItem(value: 'AFFILIATE', child: Text('가족사 임직원')), - ], - onChanged: (val) => setState(() { _affiliationType = val!; }), + // 소속 유형 선택 (가족사 메일일 경우 비활성화) + AbsorbPointer( + absorbing: _isAffiliateEmail, + child: Opacity( + opacity: _isAffiliateEmail ? 0.7 : 1.0, + child: DropdownButtonFormField( + value: _affiliationType, + decoration: InputDecoration( + labelText: '소속 유형', + border: const OutlineInputBorder(), + helperText: _isAffiliateEmail ? '가족사 이메일 사용 시 자동으로 선택됩니다.' : null, + ), + items: const [ + DropdownMenuItem(value: 'GENERAL', child: Text('일반 사용자')), + DropdownMenuItem(value: 'AFFILIATE', child: Text('가족사 임직원')), + ], + onChanged: _isAffiliateEmail ? null : (val) => setState(() { _affiliationType = val!; }), + ), + ), ), const SizedBox(height: 16), + // 가족사 선택 (가족사 메일일 경우 비활성화) if (_affiliationType == 'AFFILIATE') ...[ - DropdownButtonFormField( - value: _companyCode, - decoration: const InputDecoration(labelText: '가족사 선택', border: OutlineInputBorder()), - items: const [ - DropdownMenuItem(value: 'HANMAC', child: Text('한맥')), - DropdownMenuItem(value: 'SAMAN', child: Text('삼안')), - DropdownMenuItem(value: 'PTC', child: Text('PTC')), - DropdownMenuItem(value: 'JANGHEON', child: Text('장헌')), - DropdownMenuItem(value: 'BARON', child: Text('바론')), - DropdownMenuItem(value: 'HALLA', child: Text('한라')), - ], - onChanged: (val) => setState(() => _companyCode = val), + AbsorbPointer( + absorbing: _isAffiliateEmail, + child: Opacity( + opacity: _isAffiliateEmail ? 0.7 : 1.0, + child: DropdownButtonFormField( + value: _companyCode, + decoration: const InputDecoration(labelText: '가족사 선택', border: OutlineInputBorder()), + items: const [ + DropdownMenuItem(value: 'HANMAC', child: Text('한맥')), + DropdownMenuItem(value: 'SAMAN', child: Text('삼안')), + DropdownMenuItem(value: 'PTC', child: Text('PTC')), + DropdownMenuItem(value: 'JANGHEON', child: Text('장헌')), + DropdownMenuItem(value: 'BARON', child: Text('바론')), + DropdownMenuItem(value: 'HALLA', child: Text('한라')), + ], + onChanged: _isAffiliateEmail ? null : (val) => setState(() => _companyCode = val), + ), + ), ), const SizedBox(height: 16), ], TextFormField( controller: _deptController, + onChanged: (_) => setState(() {}), decoration: InputDecoration( labelText: _affiliationType == 'AFFILIATE' ? '부서명' : '소속 정보 (선택)', border: const OutlineInputBorder() @@ -403,8 +532,7 @@ class _SignupScreenState extends State { ); } - Widget _buildStep3() { - // 실시간 비밀번호 체크 로직 + Widget _buildStepPassword() { String p = _passwordController.text; bool hasUpper = p.contains(RegExp(r'[A-Z]')); bool hasLower = p.contains(RegExp(r'[a-z]')); @@ -415,8 +543,26 @@ class _SignupScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Text('보안 설정', style: GoogleFonts.outfit(fontSize: 16, fontWeight: FontWeight.w600)), + Text('마지막으로\n비밀번호를 설정해주세요', style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.bold)), const SizedBox(height: 16), + // 비밀번호 정책 안내 박스 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)), + child: Row( + children: [ + const Icon(Icons.security, size: 18, color: Colors.blue), + const SizedBox(width: 10), + Expanded( + child: Text( + '보안 정책: 12자 이상, 대문자/소문자/숫자/특수문자를 각각 최소 1자 이상 포함해야 합니다.', + style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500), + ), + ), + ], + ), + ), + const SizedBox(height: 24), TextFormField( controller: _passwordController, obscureText: true, @@ -428,7 +574,6 @@ class _SignupScreenState extends State { ), ), const SizedBox(height: 8), - // 실시간 체크 표시 UI Wrap( spacing: 10, children: [ @@ -445,11 +590,7 @@ class _SignupScreenState extends State { obscureText: true, onChanged: (val) { setState(() { - if (val != _passwordController.text) { - _confirmPasswordError = '비밀번호가 일치하지 않습니다.'; - } else { - _confirmPasswordError = null; - } + _confirmPasswordError = (val != _passwordController.text) ? '비밀번호가 일치하지 않습니다.' : null; }); }, decoration: InputDecoration( @@ -458,14 +599,6 @@ class _SignupScreenState extends State { errorText: _confirmPasswordError, ), ), - const SizedBox(height: 24), - CheckboxListTile( - title: const Text('이용약관 및 개인정보 처리방침 동의', style: TextStyle(fontSize: 13)), - value: _termsAccepted && _privacyAccepted, - onChanged: (val) => setState(() { _termsAccepted = val!; _privacyAccepted = val; }), - controlAffinity: ListTileControlAffinity.leading, - contentPadding: EdgeInsets.zero, - ), ], ); } @@ -484,8 +617,19 @@ class _SignupScreenState extends State { @override Widget build(BuildContext context) { bool canGoNext = false; - if (_currentStep == 1 && _isEmailVerified && _isPhoneVerified) canGoNext = true; - if (_currentStep == 2 && _nameController.text.isNotEmpty && (_affiliationType == 'GENERAL' || _companyCode != null)) canGoNext = true; + if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true; + if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true; + if (_currentStep == 3) { + final nameOk = _nameController.text.trim().isNotEmpty; + if (_affiliationType == 'GENERAL') { + canGoNext = nameOk; + } else { + // AFFILIATE 필수: 이름 + 가족사 선택 + 부서명 + final companyOk = _companyCode != null; + final deptOk = _deptController.text.trim().isNotEmpty; + canGoNext = nameOk && companyOk && deptOk; + } + } return Scaffold( backgroundColor: Colors.white, @@ -499,7 +643,7 @@ class _SignupScreenState extends State { child: Column( children: [ Padding( - padding: const EdgeInsets.symmetric(horizontal: 40), + padding: const EdgeInsets.symmetric(horizontal: 20), child: _buildStepIndicator(), ), Expanded( @@ -507,7 +651,11 @@ class _SignupScreenState extends State { padding: const EdgeInsets.all(24), child: Form( key: _formKey, - child: _currentStep == 1 ? _buildStep1() : (_currentStep == 2 ? _buildStep2() : _buildStep3()), + child: _currentStep == 1 + ? _buildStepAgreement() + : (_currentStep == 2 + ? _buildStepAuth() + : (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())), ), ), ), @@ -519,24 +667,24 @@ class _SignupScreenState extends State { Expanded( child: OutlinedButton( onPressed: () => setState(() => _currentStep--), - style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55)), - child: const Text('이전'), + style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)), + child: const Text('이전', style: TextStyle(color: Colors.black)), ), ), const SizedBox(width: 12), ], Expanded( child: FilledButton( - onPressed: _currentStep < 3 + onPressed: _currentStep < 4 ? (canGoNext ? () => setState(() => _currentStep++) : null) - : (_isLoading || !_termsAccepted ? null : _handleSignup), + : (_isLoading ? null : _handleSignup), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(55), backgroundColor: Colors.black, ), child: _isLoading ? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : Text(_currentStep < 3 ? '다음 단계' : '가입 완료'), + : Text(_currentStep < 4 ? '다음 단계' : '가입 완료'), ), ), ],