diff --git a/userfront/lib/features/auth/presentation/signup_screen.dart b/userfront/lib/features/auth/presentation/signup_screen.dart index eefa7a6f..e4f5a302 100644 --- a/userfront/lib/features/auth/presentation/signup_screen.dart +++ b/userfront/lib/features/auth/presentation/signup_screen.dart @@ -1,3 +1,4 @@ +import 'dart:math' as math; import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -18,8 +19,12 @@ class _SignupScreenState extends State { static const _signupBorder = Color(0xFFE5E7EB); static const _signupSurface = Color(0xFFF8FAFC); static const _signupMuted = Color(0xFF6B7280); + static const _signupDone = Color(0xFF0F766E); + static const _signupDoneSurface = Color(0xFFECFDF5); static const _agreementDesktopBreakpoint = 960.0; static const _agreementCardMaxWidth = 880.0; + static const _stepIndicatorDesktopBreakpoint = 720.0; + static const _stepIndicatorMaxWidth = 880.0; final _formKey = GlobalKey(); int _currentStep = 1; @@ -45,6 +50,8 @@ class _SignupScreenState extends State { bool _isLoading = false; Map? _policy; bool _isPolicyLoading = false; + bool _isPasswordObscured = true; + bool _isConfirmPasswordObscured = true; // Inline Errors String? _emailError; @@ -367,63 +374,153 @@ class _SignupScreenState extends State { // --- UI Components --- Widget _buildStepIndicator() { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Row( - children: [ - _stepCircle(1, tr('ui.userfront.signup.steps.agreement')), - _stepLine(1), - _stepCircle(2, tr('ui.userfront.signup.steps.verify')), - _stepLine(2), - _stepCircle(3, tr('ui.userfront.signup.steps.profile')), - _stepLine(3), - _stepCircle(4, tr('ui.userfront.signup.steps.password')), - ], - ), + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = + constraints.maxWidth >= _stepIndicatorDesktopBreakpoint; + final indicatorWidth = isDesktop + ? math.min(constraints.maxWidth, _stepIndicatorMaxWidth) + : constraints.maxWidth; + + return Padding( + padding: EdgeInsets.symmetric(vertical: isDesktop ? 24 : 20), + child: Align( + alignment: Alignment.topCenter, + child: SizedBox( + width: indicatorWidth, + child: Row( + children: [ + _stepCircle( + 1, + tr('ui.userfront.signup.steps.agreement'), + isDesktop: isDesktop, + ), + _stepLine(1, isDesktop: isDesktop), + _stepCircle( + 2, + tr('ui.userfront.signup.steps.verify'), + isDesktop: isDesktop, + ), + _stepLine(2, isDesktop: isDesktop), + _stepCircle( + 3, + tr('ui.userfront.signup.steps.profile'), + isDesktop: isDesktop, + ), + _stepLine(3, isDesktop: isDesktop), + _stepCircle( + 4, + tr('ui.userfront.signup.steps.password'), + isDesktop: isDesktop, + ), + ], + ), + ), + ), + ); + }, ); } - Widget _stepCircle(int step, String label) { - bool isDone = _currentStep > step; - bool isCurrent = _currentStep == step; - return Column( + Widget _stepCircle(int step, String label, {required bool isDesktop}) { + final isDone = _currentStep > step; + final isCurrent = _currentStep == step; + final circleRadius = isDesktop ? 17.0 : 12.0; + final labelStyle = TextStyle( + fontSize: isDesktop ? 12 : 9, + color: isCurrent ? _signupInk : (isDone ? _signupDone : _signupMuted), + fontWeight: isCurrent || isDone ? FontWeight.w700 : FontWeight.w500, + height: 1.2, + ); + + final stepWidget = Column( + mainAxisSize: MainAxisSize.min, 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, + AnimatedContainer( + duration: const Duration(milliseconds: 180), + width: circleRadius * 2, + height: circleRadius * 2, + decoration: BoxDecoration( + color: isDone + ? _signupDone + : (isCurrent ? _signupInk : _signupSurface), + shape: BoxShape.circle, + border: Border.all( + color: isDone + ? _signupDone + : (isCurrent ? _signupInk : _signupBorder), + width: isCurrent ? 1.5 : 1, + ), + boxShadow: isDesktop && (isCurrent || isDone) + ? const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 14, + offset: Offset(0, 8), + ), + ] + : const [], + ), + child: Center( + child: isDone + ? Icon( + Icons.check, + size: isDesktop ? 18 : 14, + color: Colors.white, + ) + : Text( + '$step', + style: TextStyle( + color: isCurrent ? Colors.white : _signupMuted, + fontSize: isDesktop ? 13 : 10, + fontWeight: FontWeight.w700, + ), ), - ), - ), - const SizedBox(height: 4), - Text( - label, - style: TextStyle( - fontSize: 9, - color: isCurrent ? Colors.black : Colors.grey, - fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal, ), ), + SizedBox(height: isDesktop ? 8 : 4), + Text(label, textAlign: TextAlign.center, style: labelStyle), ], ); + + if (!isDesktop) { + return stepWidget; + } + + return SizedBox(width: 96, child: stepWidget); } - 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 _stepLine(int afterStep, {required bool isDesktop}) { + final line = Container( + margin: EdgeInsets.only( + bottom: isDesktop ? 26 : 16, + left: isDesktop ? 10 : 2, + right: isDesktop ? 10 : 2, + ), + height: isDesktop ? 2 : 1.5, + decoration: BoxDecoration( + color: _currentStep > afterStep ? _signupDoneSurface : _signupBorder, + borderRadius: BorderRadius.circular(999), + ), + child: Align( + alignment: Alignment.centerLeft, + child: FractionallySizedBox( + widthFactor: _currentStep > afterStep ? 1 : 0, + child: Container( + decoration: BoxDecoration( + color: _signupDone, + borderRadius: BorderRadius.circular(999), + ), + ), + ), ), ); + + if (isDesktop) { + return Expanded(child: line); + } + + return Expanded(child: line); } Widget _buildStepAgreement() { @@ -921,302 +1018,695 @@ class _SignupScreenState extends State { } Widget _buildStepAuth() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.auth.title'), - style: const TextStyle(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), + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.auth.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildAuthNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildVerificationCard( + isDesktop: isDesktop, + icon: Icons.alternate_email_rounded, + title: tr('ui.userfront.signup.auth.email.title'), + label: tr('ui.userfront.signup.auth.email.label'), + hintText: 'example@hanmaceng.co.kr', + controller: _emailController, + errorText: _emailError, + readOnly: _isEmailVerified, + buttonLabel: _emailSeconds > 0 + ? tr('ui.common.resend') + : tr('ui.userfront.signup.auth.request_code'), + buttonEnabled: !_isEmailVerified && !_isLoading, + onRequestCode: _sendEmailCode, + verificationController: _emailCodeController, + verificationVisible: + _emailSeconds > 0 && !_isEmailVerified, + verificationCountdown: _formatTime(_emailSeconds), + verified: _isEmailVerified, + verifiedText: tr('msg.userfront.signup.email.verified'), + keyboardType: TextInputType.emailAddress, + onChanged: _checkEmailAffiliation, + onVerificationChanged: (val) { + if (val.length == 6) _verifyEmailCode(); + }, + ), + const SizedBox(height: 18), + _buildVerificationCard( + isDesktop: isDesktop, + icon: Icons.phone_iphone_rounded, + title: tr('ui.userfront.signup.phone.title'), + label: tr('ui.userfront.signup.phone.label'), + controller: _phoneController, + errorText: _phoneError, + readOnly: _isPhoneVerified, + buttonLabel: _phoneSeconds > 0 + ? tr('ui.common.resend') + : tr('ui.userfront.signup.auth.request_code'), + buttonEnabled: !_isPhoneVerified && !_isLoading, + onRequestCode: _sendPhoneCode, + verificationController: _phoneCodeController, + verificationVisible: + _phoneSeconds > 0 && !_isPhoneVerified, + verificationCountdown: _formatTime(_phoneSeconds), + verified: _isPhoneVerified, + verifiedText: tr('msg.userfront.signup.phone.verified'), + keyboardType: TextInputType.phone, + onVerificationChanged: (val) { + if (val.length == 6) _verifyPhoneCode(); + }, + ), + ], + ), + ), + ), ), - child: Row( - children: [ - const Icon(Icons.info_outline, size: 16, color: Colors.blue), - const SizedBox(width: 8), - Expanded( - child: Text( - tr('msg.userfront.signup.auth.affiliate_notice'), - style: const TextStyle( - fontSize: 12, - color: Colors.blue, - fontWeight: FontWeight.w500, + ); + }, + ); + } + + Widget _buildAuthNoticeCard({required bool isDesktop}) { + return DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFDBEAFE), + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.info_outline, + size: 18, + color: Color(0xFF1D4ED8), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + tr('msg.userfront.signup.auth.affiliate_notice'), + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: const Color(0xFF1E3A8A), + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildVerificationCard({ + required bool isDesktop, + required IconData icon, + required String title, + required String label, + required TextEditingController controller, + required String buttonLabel, + required bool buttonEnabled, + required Future Function() onRequestCode, + required TextEditingController verificationController, + required bool verificationVisible, + required String verificationCountdown, + required bool verified, + required String verifiedText, + String? hintText, + String? errorText, + bool readOnly = false, + TextInputType? keyboardType, + ValueChanged? onChanged, + ValueChanged? onVerificationChanged, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + Container( + width: isDesktop ? 40 : 36, + height: isDesktop ? 40 : 36, + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: _signupBorder), + ), + child: Icon(icon, size: 18, color: _signupInk), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: isDesktop ? 18 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), ), ), - ), - ], - ), - ), - const SizedBox(height: 24), - Text( - tr('ui.userfront.signup.auth.email.title'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _emailController, - onChanged: _checkEmailAffiliation, // 도메인 실시간 체크 - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.email.label'), - border: const OutlineInputBorder(), - errorText: _emailError, - hintText: 'example@hanmaceng.co.kr', - ), - readOnly: _isEmailVerified, - ), + if (verified) _buildVerifiedBadge(verifiedText), + ], ), - 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, + SizedBox(height: isDesktop ? 20 : 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: TextFormField( + controller: controller, + onChanged: onChanged, + decoration: InputDecoration( + labelText: label, + border: const OutlineInputBorder(), + errorText: errorText, + hintText: hintText, + ), + readOnly: readOnly, + keyboardType: keyboardType, + ), + ), ), - child: Text( - _emailSeconds > 0 - ? tr('ui.common.resend') - : tr('ui.userfront.signup.auth.request_code'), + const SizedBox(width: 10), + SizedBox( + height: 52, + width: isDesktop ? 108 : null, + child: FilledButton( + onPressed: buttonEnabled ? onRequestCode : null, + style: FilledButton.styleFrom( + backgroundColor: _signupInk, + foregroundColor: Colors.white, + disabledBackgroundColor: const Color(0xFFE5E7EB), + disabledForegroundColor: const Color(0xFF9CA3AF), + ), + child: Text(buttonLabel), + ), ), + ], + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (verificationVisible) ...[ + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 56), + child: TextFormField( + controller: verificationController, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.auth.code_label', + ), + suffixText: verificationCountdown, + border: const OutlineInputBorder(), + ), + keyboardType: TextInputType.number, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(6), + ], + onChanged: onVerificationChanged, + ), + ), + ), + const SizedBox(width: 10), + SizedBox(width: isDesktop ? 108 : 0), + ], + ), + ], + if (verified) ...[ + const SizedBox(height: 12), + _buildVerificationStatus(verifiedText), + ], + ], ), ), ], ), - if (_emailSeconds > 0 && !_isEmailVerified) ...[ - const SizedBox(height: 8), - TextFormField( - controller: _emailCodeController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.code_label'), - suffixText: _formatTime(_emailSeconds), - border: const OutlineInputBorder(), + ), + ); + } + + Widget _buildVerifiedBadge(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: _signupDoneSurface, + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFA7F3D0)), + ), + child: Text( + text.replaceFirst('✅ ', ''), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: _signupDone, + ), + ), + ); + } + + Widget _buildVerificationStatus(String text) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + color: _signupDoneSurface, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFA7F3D0)), + ), + child: Row( + children: [ + const Icon(Icons.check_circle, size: 18, color: _signupDone), + const SizedBox(width: 8), + Expanded( + child: Text( + text.replaceFirst('✅ ', ''), + style: const TextStyle( + color: _signupDone, + fontSize: 13, + fontWeight: FontWeight.w700, + ), ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(6), - ], - onChanged: (val) { - if (val.length == 6) _verifyEmailCode(); - }, ), ], - if (_isEmailVerified) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - tr('msg.userfront.signup.email.verified'), - style: const TextStyle( - color: Colors.green, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(height: 24), - Text( - tr('ui.userfront.signup.phone.title'), - style: const TextStyle(fontWeight: FontWeight.bold), - ), - const SizedBox(height: 8), - Row( - children: [ - Expanded( - child: TextFormField( - controller: _phoneController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.phone.label'), - 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 - ? tr('ui.common.resend') - : tr('ui.userfront.signup.auth.request_code'), - ), - ), - ), - ], - ), - if (_phoneSeconds > 0 && !_isPhoneVerified) ...[ - const SizedBox(height: 8), - TextFormField( - controller: _phoneCodeController, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.auth.code_label'), - suffixText: _formatTime(_phoneSeconds), - border: const OutlineInputBorder(), - ), - keyboardType: TextInputType.number, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(6), - ], - onChanged: (val) { - if (val.length == 6) _verifyPhoneCode(); - }, - ), - ], - if (_isPhoneVerified) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - tr('msg.userfront.signup.phone.verified'), - style: const TextStyle( - color: Colors.green, - fontSize: 13, - fontWeight: FontWeight.bold, - ), - ), - ), - ], + ), ); } Widget _buildStepInfo() { - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.profile.title'), - style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 24), - TextFormField( - controller: _nameController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.name'), - border: const OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - // 소속 유형 선택 (가족사 메일일 경우 비활성화) - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_affiliationType), - initialValue: _affiliationType, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.affiliation_type'), - border: const OutlineInputBorder(), - helperText: _isAffiliateEmail - ? tr('msg.userfront.signup.profile.affiliate_hint') - : null, + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], ), - items: [ - DropdownMenuItem( - value: 'GENERAL', - child: Text(tr('domain.affiliation.general')), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, ), - DropdownMenuItem( - value: 'AFFILIATE', - child: Text(tr('domain.affiliation.affiliate')), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.profile.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildProfileInfoNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildProfileFieldGroup( + title: tr('ui.userfront.signup.profile.name'), + description: '기본 정보', + isDesktop: isDesktop, + child: TextFormField( + controller: _nameController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: tr('ui.userfront.signup.profile.name'), + border: const OutlineInputBorder(), + ), + ), + ), + const SizedBox(height: 18), + _buildProfileFieldGroup( + title: tr('ui.userfront.signup.profile.affiliation_type'), + description: _isAffiliateEmail + ? tr('msg.userfront.signup.profile.affiliate_hint') + : '소속 유형과 회사 정보를 입력합니다.', + isDesktop: isDesktop, + trailing: _isAffiliateEmail + ? _buildAutoDetectedBadge() + : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + AbsorbPointer( + absorbing: _isAffiliateEmail, + child: Opacity( + opacity: _isAffiliateEmail ? 0.7 : 1.0, + child: DropdownButtonFormField( + key: ValueKey(_affiliationType), + initialValue: _affiliationType, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.affiliation_type', + ), + border: const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 'GENERAL', + child: Text( + tr('domain.affiliation.general'), + ), + ), + DropdownMenuItem( + value: 'AFFILIATE', + child: Text( + tr('domain.affiliation.affiliate'), + ), + ), + ], + onChanged: _isAffiliateEmail + ? null + : (val) { + if (val == null) { + return; + } + setState(() { + _affiliationType = val; + }); + }, + ), + ), + ), + AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (_affiliationType == 'AFFILIATE') ...[ + const SizedBox(height: 14), + AbsorbPointer( + absorbing: _isAffiliateEmail, + child: Opacity( + opacity: _isAffiliateEmail ? 0.7 : 1.0, + child: DropdownButtonFormField( + key: ValueKey(_companyCode ?? 'none'), + initialValue: _companyCode, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.profile.company', + ), + border: + const OutlineInputBorder(), + ), + items: [ + DropdownMenuItem( + value: 'HANMAC', + child: Text( + tr('domain.company.hanmac'), + ), + ), + DropdownMenuItem( + value: 'SAMAN', + child: Text( + tr('domain.company.saman'), + ), + ), + DropdownMenuItem( + value: 'PTC', + child: Text( + tr( + 'domain.company.ptc', + fallback: 'PTC', + ), + ), + ), + DropdownMenuItem( + value: 'JANGHEON', + child: Text( + tr('domain.company.jangheon'), + ), + ), + DropdownMenuItem( + value: 'BARON', + child: Text( + tr('domain.company.baron'), + ), + ), + DropdownMenuItem( + value: 'HALLA', + child: Text( + tr('domain.company.halla'), + ), + ), + ], + onChanged: _isAffiliateEmail + ? null + : (val) => setState( + () => _companyCode = val, + ), + ), + ), + ), + ], + ], + ), + ), + ], + ), + ), + const SizedBox(height: 18), + _buildProfileFieldGroup( + title: _affiliationType == 'AFFILIATE' + ? tr('ui.userfront.signup.profile.department') + : tr('ui.userfront.signup.profile.department_optional'), + description: _affiliationType == 'AFFILIATE' + ? '가족사 사용자는 부서명을 입력해주세요.' + : '선택 입력 항목입니다.', + isDesktop: isDesktop, + child: TextFormField( + controller: _deptController, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: _affiliationType == 'AFFILIATE' + ? tr('ui.userfront.signup.profile.department') + : tr( + 'ui.userfront.signup.profile.department_optional', + ), + border: const OutlineInputBorder(), + ), + ), + ), + ], ), - ], - onChanged: _isAffiliateEmail - ? null - : (val) { - if (val == null) { - return; - } - setState(() { - _affiliationType = val; - }); - }, + ), ), ), - ), - const SizedBox(height: 16), - // 가족사 선택 (가족사 메일일 경우 비활성화) - if (_affiliationType == 'AFFILIATE') ...[ - AbsorbPointer( - absorbing: _isAffiliateEmail, - child: Opacity( - opacity: _isAffiliateEmail ? 0.7 : 1.0, - child: DropdownButtonFormField( - key: ValueKey(_companyCode ?? 'none'), - initialValue: _companyCode, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.profile.company'), - border: const OutlineInputBorder(), + ); + }, + ); + } + + Widget _buildProfileInfoNoticeCard({required bool isDesktop}) { + final description = _isAffiliateEmail + ? '가족사 이메일이 확인되어 소속 유형이 자동으로 고정됩니다.' + : '회원가입 후 사용할 기본 소속 정보를 입력합니다.'; + + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(999), + ), + child: const Icon( + Icons.badge_outlined, + size: 18, + color: Color(0xFF4338CA), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Text( + description, + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: _signupInk, + fontWeight: FontWeight.w600, ), - items: [ - DropdownMenuItem( - value: 'HANMAC', - child: Text(tr('domain.company.hanmac')), - ), - DropdownMenuItem( - value: 'SAMAN', - child: Text(tr('domain.company.saman')), - ), - DropdownMenuItem( - value: 'PTC', - child: Text(tr('domain.company.ptc', fallback: 'PTC')), - ), - DropdownMenuItem( - value: 'JANGHEON', - child: Text(tr('domain.company.jangheon')), - ), - DropdownMenuItem( - value: 'BARON', - child: Text(tr('domain.company.baron')), - ), - DropdownMenuItem( - value: 'HALLA', - child: Text(tr('domain.company.halla')), + ), + ), + ], + ), + ), + ); + } + + Widget _buildProfileFieldGroup({ + required String title, + required String description, + required bool isDesktop, + required Widget child, + Widget? trailing, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontSize: isDesktop ? 17 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + const SizedBox(height: 6), + Text( + description, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + ], ), + ), + if (trailing != null) ...[ + const SizedBox(width: 12), + trailing, ], - onChanged: _isAffiliateEmail - ? null - : (val) => setState(() => _companyCode = val), - ), + ], ), - ), - const SizedBox(height: 16), - ], - TextFormField( - controller: _deptController, - onChanged: (_) => setState(() {}), - decoration: InputDecoration( - labelText: _affiliationType == 'AFFILIATE' - ? tr('ui.userfront.signup.profile.department') - : tr('ui.userfront.signup.profile.department_optional'), - border: const OutlineInputBorder(), - ), + SizedBox(height: isDesktop ? 18 : 14), + child, + ], ), - ], + ), + ); + } + + Widget _buildAutoDetectedBadge() { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: const Color(0xFFEEF2FF), + borderRadius: BorderRadius.circular(999), + border: Border.all(color: const Color(0xFFC7D2FE)), + ), + child: const Text( + '자동 선택', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Color(0xFF4338CA), + ), + ), ); } @@ -1265,7 +1755,7 @@ class _SignupScreenState extends State { } Widget _buildStepPassword() { - String p = _passwordController.text; + final p = _passwordController.text; // Default Policy Fallback final minLength = (_policy?['minLength'] as int?) ?? 12; @@ -1275,120 +1765,279 @@ class _SignupScreenState extends State { final requiresNumber = _policy?['number'] ?? true; final requiresSymbol = _policy?['nonAlphanumeric'] ?? true; - bool hasLength = p.length >= minLength; - 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'[!@#$%^&*(),.?":{}|<>]')); + final hasLength = p.length >= minLength; + final hasUpper = p.contains(RegExp(r'[A-Z]')); + final hasLower = p.contains(RegExp(r'[a-z]')); + final hasDigit = p.contains(RegExp(r'[0-9]')); + final hasSpecial = p.contains(RegExp(r'[!@#$%^&*(),.?":{}|<>]')); int typeCount = 0; if (hasUpper) typeCount++; if (hasLower) typeCount++; if (hasDigit) typeCount++; if (hasSpecial) typeCount++; - bool hasTypeCount = minTypes <= 0 || typeCount >= minTypes; + final hasTypeCount = minTypes <= 0 || typeCount >= minTypes; + final passwordChecks = [ + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.min_length', + params: {'count': minLength.toString()}, + ), + hasLength, + ), + if (minTypes > 0) + _cryptoCheck( + tr( + 'msg.userfront.signup.password.rule.min_types', + params: {'count': minTypes.toString()}, + ), + hasTypeCount, + ), + if (requiresUpper) + _cryptoCheck(tr('msg.userfront.signup.password.rule.uppercase'), hasUpper), + if (requiresLower) + _cryptoCheck(tr('msg.userfront.signup.password.rule.lowercase'), hasLower), + if (requiresNumber) + _cryptoCheck(tr('msg.userfront.signup.password.rule.number'), hasDigit), + if (requiresSymbol) + _cryptoCheck(tr('msg.userfront.signup.password.rule.symbol'), hasSpecial), + ]; - return Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - tr('msg.userfront.signup.password.title'), - style: const TextStyle(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( - _buildPolicyDescription(), - 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: tr('ui.userfront.signup.password.label'), - border: const OutlineInputBorder(), - errorText: _passwordError, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 10, - children: [ - _cryptoCheck( - tr( - 'msg.userfront.signup.password.rule.min_length', - params: {'count': minLength.toString()}, - ), - hasLength, + return LayoutBuilder( + builder: (context, constraints) { + final isDesktop = constraints.maxWidth >= _agreementDesktopBreakpoint; + + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isDesktop + ? _agreementCardMaxWidth + : constraints.maxWidth, ), - if (minTypes > 0) - _cryptoCheck( - tr( - 'msg.userfront.signup.password.rule.min_types', - params: {'count': minTypes.toString()}, + child: DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(isDesktop ? 24 : 18), + border: Border.all(color: _signupBorder), + boxShadow: isDesktop + ? const [ + BoxShadow( + color: Color(0x12000000), + blurRadius: 32, + offset: Offset(0, 18), + ), + ] + : const [], + ), + child: Padding( + padding: EdgeInsets.symmetric( + horizontal: isDesktop ? 32 : 20, + vertical: isDesktop ? 32 : 24, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + tr('msg.userfront.signup.password.title'), + style: TextStyle( + fontSize: isDesktop ? 28 : 20, + fontWeight: FontWeight.w700, + height: 1.25, + color: _signupInk, + ), + ), + const SizedBox(height: 12), + _buildPasswordPolicyNoticeCard(isDesktop: isDesktop), + SizedBox(height: isDesktop ? 28 : 24), + _buildPasswordFieldGroup( + title: tr('ui.userfront.signup.password.label'), + description: '보안 정책에 맞는 비밀번호를 입력합니다.', + isDesktop: isDesktop, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + TextFormField( + controller: _passwordController, + obscureText: _isPasswordObscured, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + labelText: tr('ui.userfront.signup.password.label'), + border: const OutlineInputBorder(), + errorText: _passwordError, + suffixIcon: IconButton( + onPressed: () { + setState(() { + _isPasswordObscured = + !_isPasswordObscured; + }); + }, + icon: Icon( + _isPasswordObscured + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + ), + const SizedBox(height: 14), + _buildPasswordChecksCard( + checks: passwordChecks, + isDesktop: isDesktop, + ), + ], + ), + ), + const SizedBox(height: 18), + _buildPasswordFieldGroup( + title: tr('ui.userfront.signup.password.confirm_label'), + description: '입력한 비밀번호를 한 번 더 확인합니다.', + isDesktop: isDesktop, + child: TextFormField( + controller: _confirmPasswordController, + obscureText: _isConfirmPasswordObscured, + onChanged: (val) { + setState(() { + _confirmPasswordError = (val != + _passwordController.text) + ? tr('msg.userfront.signup.password.mismatch') + : null; + }); + }, + decoration: InputDecoration( + labelText: tr( + 'ui.userfront.signup.password.confirm_label', + ), + border: const OutlineInputBorder(), + errorText: _confirmPasswordError, + suffixIcon: IconButton( + onPressed: () { + setState(() { + _isConfirmPasswordObscured = + !_isConfirmPasswordObscured; + }); + }, + icon: Icon( + _isConfirmPasswordObscured + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + ), + ), + ), + ), + ), + ], ), - hasTypeCount, ), - if (requiresUpper) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.uppercase'), - hasUpper, + ), + ), + ); + }, + ); + } + + Widget _buildPasswordPolicyNoticeCard({required bool isDesktop}) { + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 18 : 14), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isDesktop ? 36 : 32, + height: isDesktop ? 36 : 32, + decoration: BoxDecoration( + color: const Color(0xFFDBEAFE), + borderRadius: BorderRadius.circular(999), ), - if (requiresLower) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.lowercase'), - hasLower, + child: const Icon( + Icons.security_rounded, + size: 18, + color: Color(0xFF1D4ED8), ), - if (requiresNumber) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.number'), - hasDigit, - ), - if (requiresSymbol) - _cryptoCheck( - tr('msg.userfront.signup.password.rule.symbol'), - hasSpecial, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + _buildPolicyDescription(), + style: TextStyle( + fontSize: isDesktop ? 14 : 12, + height: 1.4, + color: const Color(0xFF1E3A8A), + fontWeight: FontWeight.w600, + ), ), + ), ], ), - const SizedBox(height: 16), - TextFormField( - controller: _confirmPasswordController, - obscureText: true, - onChanged: (val) { - setState(() { - _confirmPasswordError = (val != _passwordController.text) - ? tr('msg.userfront.signup.password.mismatch') - : null; - }); - }, - decoration: InputDecoration( - labelText: tr('ui.userfront.signup.password.confirm_label'), - border: const OutlineInputBorder(), - errorText: _confirmPasswordError, - ), + ), + ); + } + + Widget _buildPasswordFieldGroup({ + required String title, + required String description, + required bool isDesktop, + required Widget child, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 20 : 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + title, + style: TextStyle( + fontSize: isDesktop ? 17 : 15, + fontWeight: FontWeight.w700, + color: _signupInk, + ), + ), + const SizedBox(height: 6), + Text( + description, + style: const TextStyle( + fontSize: 13, + height: 1.45, + color: _signupMuted, + ), + ), + SizedBox(height: isDesktop ? 18 : 14), + child, + ], ), - ], + ), + ); + } + + Widget _buildPasswordChecksCard({ + required List checks, + required bool isDesktop, + }) { + return DecoratedBox( + decoration: BoxDecoration( + color: _signupSurface, + borderRadius: BorderRadius.circular(14), + border: Border.all(color: _signupBorder), + ), + child: Padding( + padding: EdgeInsets.all(isDesktop ? 16 : 14), + child: Wrap( + spacing: 12, + runSpacing: 10, + children: checks, + ), + ), ); }