1
0
forked from baron/baron-sso
Files
baron-sso/frontend/lib/features/auth/presentation/signup_screen.dart

698 lines
25 KiB
Dart

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<SignupScreen> createState() => _SignupScreenState();
}
class _SignupScreenState extends State<SignupScreen> {
final _formKey = GlobalKey<FormState>();
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<String, String> _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<void> _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<void> _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<void> _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<void> _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<void> _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<bool?> 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<String>(
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<String>(
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 ? '다음 단계' : '가입 완료'),
),
),
],
),
),
],
),
),
);
}
}