import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:go_router/go_router.dart'; import '../../../core/services/auth_proxy_service.dart'; class SignupScreen extends StatefulWidget { const SignupScreen({super.key}); @override State createState() => _SignupScreenState(); } class _SignupScreenState extends State { final _formKey = GlobalKey(); int _currentStep = 1; // Controllers final _emailController = TextEditingController(); final _emailCodeController = TextEditingController(); final _phoneController = TextEditingController(); final _phoneCodeController = TextEditingController(); final _nameController = TextEditingController(); final _passwordController = TextEditingController(); final _confirmPasswordController = TextEditingController(); final _deptController = TextEditingController(); // State bool _isEmailVerified = false; bool _isPhoneVerified = false; String _affiliationType = 'GENERAL'; String? _companyCode; bool _isAffiliateEmail = false; // 가족사 이메일 여부 bool _termsAccepted = false; bool _privacyAccepted = false; bool _isLoading = false; // Inline Errors String? _emailError; String? _phoneError; String? _passwordError; String? _confirmPasswordError; // Timers Timer? _emailTimer; int _emailSeconds = 0; 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(); _phoneTimer?.cancel(); _emailController.dispose(); _emailCodeController.dispose(); _phoneController.dispose(); _phoneCodeController.dispose(); _nameController.dispose(); _passwordController.dispose(); _confirmPasswordController.dispose(); _deptController.dispose(); super.dispose(); } // 이메일 입력 시 도메인 체크 로직 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') { _emailSeconds = 300; _emailTimer?.cancel(); _emailTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) return; setState(() { if (_emailSeconds > 0) _emailSeconds--; else timer.cancel(); }); }); } else { _phoneSeconds = 180; _phoneTimer?.cancel(); _phoneTimer = Timer.periodic(const Duration(seconds: 1), (timer) { if (!mounted) return; setState(() { if (_phoneSeconds > 0) _phoneSeconds--; else timer.cancel(); }); }); } } String _formatTime(int seconds) { final m = seconds ~/ 60; final s = seconds % 60; return '${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')}'; } Future _sendEmailCode() async { final email = _emailController.text.trim(); final emailRegex = RegExp(r'^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$'); if (!emailRegex.hasMatch(email)) { setState(() => _emailError = '유효한 이메일 형식이 아닙니다.'); return; } setState(() { _isLoading = true; _emailError = null; }); try { final available = await AuthProxyService.checkEmailAvailability(email); if (!available) { setState(() => _emailError = '이미 가입된 이메일입니다.'); return; } await AuthProxyService.sendSignupCode(email, 'email'); _startTimer('email'); } catch (e) { setState(() => _emailError = '발송 실패: $e'); } finally { setState(() => _isLoading = false); } } Future _verifyEmailCode() async { final code = _emailCodeController.text.trim(); if (code.length != 6) return; try { final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code); if (success) { setState(() { _isEmailVerified = true; _emailTimer?.cancel(); _emailSeconds = 0; _emailError = null; }); } else { setState(() => _emailError = '인증코드가 일치하지 않습니다.'); } } catch (e) { setState(() => _emailError = '인증 실패: $e'); } } Future _sendPhoneCode() async { final phone = _phoneController.text.trim(); if (phone.isEmpty) return; setState(() { _isLoading = true; _phoneError = null; }); try { await AuthProxyService.sendSignupCode(phone, 'phone'); _startTimer('phone'); } catch (e) { setState(() => _phoneError = '발송 실패: $e'); } finally { setState(() => _isLoading = false); } } Future _verifyPhoneCode() async { final code = _phoneCodeController.text.trim(); if (code.length != 6) return; try { final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code); if (success) { setState(() { _isPhoneVerified = true; _phoneTimer?.cancel(); _phoneSeconds = 0; _phoneError = null; }); } else { setState(() => _phoneError = '인증코드가 일치하지 않습니다.'); } } catch (e) { setState(() => _phoneError = '인증 실패: $e'); } } Future _handleSignup() async { if (_passwordController.text != _confirmPasswordController.text) { setState(() => _confirmPasswordError = '비밀번호가 일치하지 않습니다.'); return; } if (!_formKey.currentState!.validate()) return; setState(() { _isLoading = true; _passwordError = null; }); try { await AuthProxyService.signup( email: _emailController.text.trim(), password: _passwordController.text, name: _nameController.text.trim(), phone: _phoneController.text.trim(), affiliationType: _affiliationType, companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null, department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(), termsAccepted: true, ); if (mounted) _showSuccessDialog(); } catch (e) { String eStr = e.toString().toLowerCase(); setState(() { if (eStr.contains('uppercase')) _passwordError = '대문자가 최소 1개 이상 포함되어야 합니다.'; else if (eStr.contains('lowercase')) _passwordError = '소문자가 최소 1개 이상 포함되어야 합니다.'; else if (eStr.contains('digit') || eStr.contains('number')) _passwordError = '숫자가 최소 1개 이상 포함되어야 합니다.'; else if (eStr.contains('symbol') || eStr.contains('special')) _passwordError = '특수문자가 최소 1개 이상 포함되어야 합니다.'; else if (eStr.contains('length') || eStr.contains('12 characters')) _passwordError = '비밀번호는 최소 12자 이상이어야 합니다.'; else _passwordError = '가입 실패: $e'; }); } finally { setState(() => _isLoading = false); } } void _showSuccessDialog() { showDialog( context: context, barrierDismissible: false, builder: (context) => AlertDialog( title: const Text('회원가입 완료'), content: const Text('성공적으로 가입되었습니다.'), actions: [TextButton(onPressed: () => context.go('/login'), child: const Text('로그인하기'))], ), ); } // --- UI Components --- Widget _buildStepIndicator() { return Padding( padding: const EdgeInsets.symmetric(vertical: 20), child: Row( children: [ _stepCircle(1, '약관동의'), _stepLine(1), _stepCircle(2, '본인인증'), _stepLine(2), _stepCircle(3, '정보입력'), _stepLine(3), _stepCircle(4, '비밀번호'), ], ), ); } Widget _stepCircle(int step, String label) { bool isDone = _currentStep > step; bool isCurrent = _currentStep == step; return Column( children: [ CircleAvatar( radius: 12, backgroundColor: isDone ? Colors.green : (isCurrent ? Colors.black : Colors.grey[300]), 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: 9, color: isCurrent ? Colors.black : Colors.grey, fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal)), ], ); } Widget _stepLine(int afterStep) { return Expanded( child: Container( margin: const EdgeInsets.only(bottom: 16, left: 2, right: 2), height: 1.5, color: _currentStep > afterStep ? Colors.green : Colors.grey[300], ), ); } Widget _buildStepAgreement() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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, onChanged: _checkEmailAffiliation, // 도메인 실시간 체크 decoration: InputDecoration( labelText: '이메일 주소', border: const OutlineInputBorder(), errorText: _emailError, hintText: 'example@hanmaceng.co.kr', ), readOnly: _isEmailVerified, ), ), const SizedBox(width: 8), SizedBox( 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 ? '재발송' : '인증요청'), ), ), ], ), if (_emailSeconds > 0 && !_isEmailVerified) ...[ const SizedBox(height: 8), TextFormField( controller: _emailCodeController, decoration: InputDecoration( labelText: '인증코드 6자리', suffixText: _formatTime(_emailSeconds), border: const OutlineInputBorder(), ), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], onChanged: (val) { if(val.length == 6) _verifyEmailCode(); }, ), ], if (_isEmailVerified) const Padding( padding: EdgeInsets.only(top: 8), child: Text('✅ 이메일 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)), ), const SizedBox(height: 24), Text('휴대폰 인증', style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), Row( children: [ Expanded( child: TextFormField( controller: _phoneController, decoration: InputDecoration(labelText: '휴대폰 번호 (-없이)', border: const OutlineInputBorder(), errorText: _phoneError), readOnly: _isPhoneVerified, keyboardType: TextInputType.phone, ), ), const SizedBox(width: 8), SizedBox( 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 ? '재발송' : '인증요청'), ), ), ], ), if (_phoneSeconds > 0 && !_isPhoneVerified) ...[ const SizedBox(height: 8), TextFormField( controller: _phoneCodeController, decoration: InputDecoration( labelText: '인증코드 6자리', suffixText: _formatTime(_phoneSeconds), border: const OutlineInputBorder(), ), keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)], onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); }, ), ], if (_isPhoneVerified) const Padding( padding: EdgeInsets.only(top: 8), child: Text('✅ 휴대폰 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)), ), ], ); } Widget _buildStepInfo() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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), // 소속 유형 선택 (가족사 메일일 경우 비활성화) 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') ...[ 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() ), ), ], ); } Widget _buildStepPassword() { String p = _passwordController.text; bool hasUpper = p.contains(RegExp(r'[A-Z]')); bool hasLower = p.contains(RegExp(r'[a-z]')); bool hasDigit = p.contains(RegExp(r'[0-9]')); bool hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); bool hasLength = p.length >= 12; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ 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, onChanged: (_) => setState(() {}), decoration: InputDecoration( labelText: '비밀번호', border: const OutlineInputBorder(), errorText: _passwordError, ), ), const SizedBox(height: 8), Wrap( spacing: 10, children: [ _cryptoCheck('12자 이상', hasLength), _cryptoCheck('대문자', hasUpper), _cryptoCheck('소문자', hasLower), _cryptoCheck('숫자', hasDigit), _cryptoCheck('특수문자', hasSpecial), ], ), const SizedBox(height: 16), TextFormField( controller: _confirmPasswordController, obscureText: true, onChanged: (val) { setState(() { _confirmPasswordError = (val != _passwordController.text) ? '비밀번호가 일치하지 않습니다.' : null; }); }, decoration: InputDecoration( labelText: '비밀번호 확인', border: const OutlineInputBorder(), errorText: _confirmPasswordError, ), ), ], ); } Widget _cryptoCheck(String label, bool isValid) { return Row( mainAxisSize: MainAxisSize.min, children: [ Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey), const SizedBox(width: 4), Text(label, style: TextStyle(fontSize: 11, color: isValid ? Colors.green : Colors.grey)), ], ); } @override Widget build(BuildContext context) { bool canGoNext = false; 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, appBar: AppBar( title: Text('회원가입', style: GoogleFonts.outfit(fontWeight: FontWeight.bold)), elevation: 0, backgroundColor: Colors.white, foregroundColor: Colors.black, ), body: SafeArea( child: Column( children: [ Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: _buildStepIndicator(), ), Expanded( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: Form( key: _formKey, child: _currentStep == 1 ? _buildStepAgreement() : (_currentStep == 2 ? _buildStepAuth() : (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())), ), ), ), Padding( padding: const EdgeInsets.all(24), child: Row( children: [ if (_currentStep > 1) ...[ Expanded( child: OutlinedButton( onPressed: () => setState(() => _currentStep--), 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 < 4 ? (canGoNext ? () => setState(() => _currentStep++) : null) : (_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 < 4 ? '다음 단계' : '가입 완료'), ), ), ], ), ), ], ), ), ); } }