forked from baron/baron-sso
1307 lines
52 KiB
Dart
1307 lines
52 KiB
Dart
import 'dart:async';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:userfront/i18n.dart';
|
|
import '../../../core/i18n/locale_utils.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;
|
|
Map<String, dynamic>? _policy;
|
|
bool _isPolicyLoading = 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 initState() {
|
|
super.initState();
|
|
_loadPolicy();
|
|
}
|
|
|
|
Future<void> _loadPolicy() async {
|
|
setState(() => _isPolicyLoading = true);
|
|
try {
|
|
final policy = await AuthProxyService.fetchPasswordPolicy();
|
|
if (mounted) setState(() => _policy = policy);
|
|
} catch (_) {
|
|
// Ignore errors, will use defaults
|
|
} finally {
|
|
if (mounted) setState(() => _isPolicyLoading = false);
|
|
}
|
|
}
|
|
|
|
@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 = tr('msg.userfront.signup.email.invalid'));
|
|
return;
|
|
}
|
|
setState(() {
|
|
_isLoading = true;
|
|
_emailError = null;
|
|
});
|
|
try {
|
|
final available = await AuthProxyService.checkEmailAvailability(email);
|
|
if (!available) {
|
|
setState(
|
|
() => _emailError = tr('msg.userfront.signup.email.duplicate'),
|
|
);
|
|
return;
|
|
}
|
|
await AuthProxyService.sendSignupCode(email, 'email');
|
|
_startTimer('email');
|
|
} catch (e) {
|
|
setState(
|
|
() => _emailError = tr(
|
|
'msg.userfront.signup.email.send_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
} 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 = tr('msg.userfront.signup.email.code_mismatch'),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
setState(
|
|
() => _emailError = tr(
|
|
'msg.userfront.signup.email.verify_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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 = tr(
|
|
'msg.userfront.signup.phone.send_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
} 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 = tr('msg.userfront.signup.phone.code_mismatch'),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
setState(
|
|
() => _phoneError = tr(
|
|
'msg.userfront.signup.phone.verify_failed',
|
|
params: {'error': e.toString()},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _handleSignup() async {
|
|
if (_passwordController.text != _confirmPasswordController.text) {
|
|
setState(
|
|
() => _confirmPasswordError = tr(
|
|
'msg.userfront.signup.password.mismatch',
|
|
),
|
|
);
|
|
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 = tr(
|
|
'msg.userfront.signup.password.uppercase_required',
|
|
);
|
|
} else if (eStr.contains('lowercase')) {
|
|
_passwordError = tr(
|
|
'msg.userfront.signup.password.lowercase_required',
|
|
);
|
|
} else if (eStr.contains('digit') || eStr.contains('number')) {
|
|
_passwordError = tr('msg.userfront.signup.password.number_required');
|
|
} else if (eStr.contains('symbol') || eStr.contains('special')) {
|
|
_passwordError = tr('msg.userfront.signup.password.symbol_required');
|
|
} else if (eStr.contains('length') || eStr.contains('12 characters')) {
|
|
_passwordError = tr('msg.userfront.signup.password.length_required');
|
|
} else {
|
|
_passwordError = tr(
|
|
'msg.userfront.signup.failed',
|
|
params: {'error': e.toString()},
|
|
);
|
|
}
|
|
});
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
void _showSuccessDialog() {
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: false,
|
|
builder: (context) => AlertDialog(
|
|
title: Text(tr('msg.userfront.signup.success.title')),
|
|
content: Text(tr('msg.userfront.signup.success.body')),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => context.go(buildLocalizedSigninPath(Uri.base)),
|
|
child: Text(tr('ui.userfront.signup.success.action')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// --- 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')),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
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(
|
|
tr('msg.userfront.signup.agreement.title'),
|
|
style: const TextStyle(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.bold,
|
|
height: 1.3,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
// 모두 동의 버튼
|
|
Container(
|
|
decoration: BoxDecoration(
|
|
color: Colors.grey[50],
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(color: Colors.grey[200]!),
|
|
),
|
|
child: CheckboxListTile(
|
|
title: Text(
|
|
tr('ui.userfront.signup.agreement.all'),
|
|
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
|
),
|
|
value: _termsAccepted && _privacyAccepted,
|
|
onChanged: (val) {
|
|
setState(() {
|
|
_termsAccepted = val!;
|
|
_privacyAccepted = val;
|
|
});
|
|
},
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
activeColor: Colors.black,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_agreementSection(
|
|
title: tr('ui.userfront.signup.agreement.tos_title'),
|
|
content: _tosText,
|
|
value: _termsAccepted,
|
|
onChanged: (val) => setState(() => _termsAccepted = val!),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_agreementSection(
|
|
title: tr('ui.userfront.signup.agreement.privacy_title'),
|
|
content: _privacyText,
|
|
value: _privacyAccepted,
|
|
onChanged: (val) => setState(() => _privacyAccepted = val!),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _agreementSection({
|
|
required String title,
|
|
required String content,
|
|
required bool value,
|
|
required ValueChanged<bool?> onChanged,
|
|
}) {
|
|
return Column(
|
|
children: [
|
|
CheckboxListTile(
|
|
title: Text(
|
|
title,
|
|
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
|
),
|
|
value: value,
|
|
onChanged: onChanged,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: EdgeInsets.zero,
|
|
activeColor: Colors.black,
|
|
),
|
|
Container(
|
|
height: 120,
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border.all(color: Colors.grey[300]!),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: Text(
|
|
content,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
color: Colors.grey,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
static String get _tosText => tr(
|
|
'msg.userfront.signup.tos_full',
|
|
fallback: """
|
|
바론 소프트웨어 이용약관
|
|
|
|
제1장 총칙
|
|
제1조 (목적)
|
|
이 약관은 바론컨설턴트(이하 "회사"라 합니다)가 제공하는 바론소프트웨어(이하 "서비스"라 합니다)를 이용함에 있어 회사와 이용자 간의 권리, 의무 및 책임사항과 기타 필요한 사항을 정하는 것을 목적으로 합니다.
|
|
제2조 (용어의 정의)
|
|
① 본 약관에서 사용하는 용어의 정의는 다음과 같습니다:
|
|
- “서비스”란 회사가 제공하는 소프트웨어 및 관련 제반 서비스를 의미합니다.
|
|
- “이용자”란 회사의 서비스에 접속하여 본 약관에 따라 회사가 제공하는 서비스를 이용하는 회원 및 비회원을 말합니다.
|
|
- “회원”이란 본 약관에 동의하고 회사와 이용계약을 체결한 자를 의미합니다.
|
|
- “비회원”이란 회원가입을 하지 않고 회사가 제공하는 일부 서비스를 이용하는 자를 말합니다.
|
|
제3조 (약관의 효력 및 변경)
|
|
① 본 약관은 이용자가 본 약관에 동의하고, 회사가 이에 대한 승낙을 완료함으로써 효력이 발생합니다. ② 회사는 필요한 경우 본 약관을 변경할 수 있으며, 변경된 약관은 서비스 화면에 공지된 후 효력이 발생합니다.
|
|
제4조 (약관 외 준칙)
|
|
본 약관에 명시되지 않은 사항에 대해서는 대한민국의 관련 법령과 상관습에 따릅니다.
|
|
제2장 서비스 이용계약
|
|
제5조 (이용계약의 성립)
|
|
이용계약은 이용자가 약관의 내용에 동의하고, 회사가 제공하는 소정의 회원가입 신청서를 작성하여 가입을 완료한 후, 회사가 이를 승인함으로써 성립합니다.
|
|
제6조 (이용계약의 유보와 거절)
|
|
① 회사는 다음 각 호에 해당하는 경우 이용계약의 성립을 유보하거나 거절할 수 있습니다: - 신청서의 내용이 허위로 판명된 경우 - 서비스 제공이 기술적으로 어려운 경우
|
|
제7조 (계약사항의 변경)
|
|
회원은 개인정보 관리 메뉴를 통해 언제든지 자신의 정보를 열람하고 수정할 수 있습니다. 회원의 정보가 변경된 경우 즉시 수정해야 하며, 수정하지 않아 발생하는 문제의 책임은 회원에게 있습니다.
|
|
제3장 개인정보 보호
|
|
제8조 (개인정보 보호의 원칙)
|
|
① 회원의 개인정보는 관련 법령에 따라 보호됩니다. ② 회사는 개인정보 보호와 관련된 세부 사항을 별도로 마련한 개인정보처리방침에 따라 관리하며, 이용자는 언제든지 해당 방침을 통해 개인정보 관리에 대한 자세한 내용을 확인할 수 있습니다.
|
|
제9조 (개인정보처리방침 준수)
|
|
① 회사는 개인정보 보호와 관련된 구체적인 사항을 개인정보처리방침에 따라 관리합니다. ② 개인정보의 수집, 이용, 제공, 보관, 보호 등에 관한 사항은 회사의 개인정보처리방침을 따르며, 이용자는 회사 웹사이트에서 이를 확인할 수 있습니다. ③ 회사는 개인정보 보호를 위해 최선을 다하며, 관련 법령에 따라 이용자의 개인정보를 안전하게 관리합니다.
|
|
제10조 (14세 미만 아동의 개인정보 보호)
|
|
① 회사는 14세 미만 아동의 개인정보를 수집할 경우, 반드시 법정대리인의 동의를 받아야 합니다. ② 법정대리인은 아동의 개인정보 열람, 수정, 삭제를 요청할 수 있으며, 회사는 이를 신속하게 처리합니다. ③ 14세 미만 아동의 개인정보 보호와 관련된 구체적인 사항은 개인정보처리방침에 명시되어 있습니다.
|
|
제4장 서비스 제공 및 이용
|
|
제11조 (서비스 제공)
|
|
회사는 회원의 이용 신청을 승인한 때부터 서비스를 개시합니다. 서비스 이용은 연중무휴 24시간을 원칙으로 합니다.
|
|
제12조 (서비스의 변경 및 중단)
|
|
회사는 서비스 제공이 어려운 경우 사전 고지 후 서비스를 변경하거나 중단할 수 있습니다.
|
|
제5장 정보 제공 및 광고
|
|
제13조 (정보 제공 및 광고)
|
|
① 회사는 서비스 이용 중 필요하다고 인정되는 정보 및 광고를 제공할 수 있습니다. ② 회원은 원치 않는 정보를 수신 거부할 수 있습니다.
|
|
제6장 게시물 관리
|
|
제14조 (게시물의 관리)
|
|
회사는 회원이 게시한 내용이 불법적이거나 약관에 위배될 경우 이를 삭제할 수 있습니다.
|
|
제15조 (게시물의 저작권)
|
|
게시물의 저작권은 회원에게 있으며, 회사는 이를 서비스 홍보 및 개선 목적으로 사용할 수 있습니다.
|
|
제7장 계약 해지 및 이용 제한
|
|
제16조 (계약 해지)
|
|
회원은 언제든지 계약 해지를 요청할 수 있으며, 회사는 신속하게 처리합니다.
|
|
제17조 (이용 제한)
|
|
회사는 회원이 약관을 위반할 경우 서비스 이용을 제한할 수 있습니다.
|
|
제8장 손해 배상 및 면책 조항
|
|
제18조 (손해 배상)
|
|
회사는 무료로 제공되는 서비스와 관련하여 회원에게 발생한 손해에 대해 책임을 지지 않습니다.
|
|
제19조 (면책 조항)
|
|
회사는 천재지변 등 불가항력적인 사유로 인해 서비스를 제공하지 못하는 경우 책임을 지지 않습니다.
|
|
제9장 유료 서비스
|
|
20조 (유료 서비스의 이용)
|
|
① 회사는 회원에게 특정 서비스에 대해 유료로 제공할 수 있습니다. ② 유료 서비스의 이용 요금, 결제 방식, 환불 절차 등에 대한 상세 내용은 서비스 안내 페이지와 결제 화면에 명시합니다. ③ 유료 서비스 이용 요금은 회사가 정한 결제 방식에 따라 결제됩니다. 회원은 신용카드, 계좌이체, 휴대전화 결제 등 회사가 제공하는 다양한 결제 방식을 통해 요금을 납부할 수 있습니다. ④ 유료 서비스의 이용 요금은 선불 결제를 원칙으로 하며, 이용 기간 중 서비스 중지 및 해지 시 남은 이용 기간에 대한 환불은 회사의 환불 정책에 따라 처리됩니다. ⑤ 회사는 회원의 유료 서비스 이용과 관련하여 발생한 문제에 대해 최선을 다해 해결하도록 노력합니다. 다만, 회사의 고의 또는 중대한 과실이 없는 한 회원이 유료 서비스 이용 중 입은 손해에 대해서는 책임을 지지 않습니다.
|
|
제21조(환불 정책)
|
|
① 회원은 결제 후 7일 이내에 서비스 이용을 시작하지 않은 경우, 요금 전액을 환불받을 수 있습니다. ② 유료 서비스 이용 중 부득이한 사유로 서비스가 중지된 경우, 회사는 이용하지 않은 부분에 대해 환불 절차를 밟습니다. ③ 회원의 귀책사유로 인해 서비스 이용이 중지된 경우, 환불이 불가능합니다. ④ 환불은 회원이 지정한 계좌로 환불 절차를 거치며, 환불 요청 후 7일 이내에 처리됩니다.
|
|
제22조 (유료 서비스의 중지 및 해지)
|
|
① 회원이 유료 서비스를 해지하고자 하는 경우, 회사의 고객 지원 센터에 해지 신청을 해야 합니다. ② 회사는 회원이 약관을 위반하거나 부정한 방법으로 유료 서비스를 이용한 경우, 유료 서비스 이용을 즉시 중지하고 계약을 해지할 수 있습니다.
|
|
제10장 양도 금지
|
|
제23조 (양도 금지)
|
|
회원은 서비스 이용권한, 기타 이용계약상의 지위를 제3자에게 양도, 증여할 수 없으며, 이를 담보로 제공할 수 없습니다.
|
|
제11장 관할 법원
|
|
제24조 (분쟁 해결)
|
|
서비스 이용과 관련하여 분쟁이 발생한 경우, 회사와 회원은 성실히 협의하여 해결합니다.
|
|
제25조 (관할 법원)
|
|
본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.
|
|
부칙
|
|
본 약관은 2024년 10월 1일부터 시행됩니다.
|
|
""",
|
|
);
|
|
|
|
static String get _privacyText => tr(
|
|
'msg.userfront.signup.privacy_full',
|
|
fallback: """
|
|
개인정보 수집 및 이용 동의
|
|
|
|
바론서비스 개인정보처리방침
|
|
|
|
제1조 (목적)
|
|
바론컨설턴트(이하 "회사")는 바론서비스(이하 "서비스")를 이용하는 고객(이하 "이용자")의 개인정보를 보호하고, 「개인정보 보호법」에 따라 책임과 의무를 다하기 위해 본 개인정보처리방침을 마련했습니다. 본 방침은 이용자가 제공한 개인정보가 어떻게 수집, 이용, 보관, 보호되는지를 설명합니다.
|
|
제2조 (개인정보의 처리목적)
|
|
회사는 다음의 목적을 위해 개인정보를 처리합니다. 처리하고 있는 개인정보는 다음의 목적 이외의 용도로는 이용되지 않으며, 이용 목적이 변경되는 경우에는 「개인정보 보호법」 제18조에 따라 별도의 동의를 받는 등 필요한 조치를 이행할 예정입니다.
|
|
- 본인확인: 회원가입 및 관리를 위한 본인 확인, 전화 또는 이메일을 통한 연락
|
|
- 서비스 제공: 각종 통보 및 서비스 제공을 위한 업무 처리
|
|
- 제품소개서 다운로드: 설명자료 전달
|
|
- 상담 및 데모 신청: 상담 제공 및 데모 제공, 계약 처리자 정보 수집
|
|
- 행사 참가 신청: 참석 안내 및 세미나/설명회/교육 제공
|
|
- 보안가이드 제공: 안내자료 전달
|
|
- 기술지원 문의: 서비스 사용 지원
|
|
- 서비스 개선 의견 접수: 서비스 품질 개선
|
|
- 마케팅 활동: 동의한 고객에 한해 뉴스레터 및 매거진 발송
|
|
제3조 (개인정보의 처리 및 보유 기간)
|
|
① 회사는 법령에 따른 개인정보 보유 및 이용기간 또는 정보주체로부터 개인정보를 수집 시 동의받은 개인정보 보유 및 이용기간 내에서 개인정보를 처리 및 보유합니다.
|
|
② 각각의 개인정보 처리 및 보유 기간은 다음과 같습니다:
|
|
- 회원정보: 회원가입일부터 회원탈퇴 후 1년까지
|
|
- 홍보, 상담, 계약용 개인정보: 2년
|
|
제4조 (개인정보의 제3자 제공)
|
|
① 회사는 정보주체의 개인정보를 제2조에서 명시한 범위 내에서만 처리하며, 정보 주체의 동의, 법률의 특별한 규정 등 「개인정보 보호법」 제17조 및 제18조에 해당하는 경우에만 개인정보를 제3자에게 제공합니다.
|
|
② 회사는 다음과 같이 개인정보를 제3자에게 제공하고 있습니다:
|
|
- 제공받는 자: 수사기관 및 유관기관, 피신고업체
|
|
- 이용 목적: 개인정보 침해 민원 처리
|
|
- 제공하는 개인정보 항목: 성명, 연락처, 이메일
|
|
- 보유 및 이용기간: 법령에서 정한 보존기간 및 제공목적 달성 시 파기
|
|
제5조 (개인정보 처리 위탁)
|
|
① 회사는 개인정보 처리업무를 외부 업체에 위탁하지 않으며, 자체적으로 처리하고 있습니다.
|
|
② 회사가 특정 업무(예: 채용 업무)를 외부 업체에 위탁할 경우, 개인정보 처리방침 시행 전 회사 홈페이지에서 공지한 후 정보주체의 동의를 받은 후 위탁합니다.
|
|
제6조 (정보주체의 권리·의무 및 행사 방법)
|
|
① 정보주체는 회사에 대해 언제든지 개인정보 열람, 정정, 삭제, 처리정지 요구 등의 권리를 행사할 수 있습니다.
|
|
② 권리 행사는 다음과 같은 방법으로 할 수 있습니다:
|
|
- 서면: 회사 주소로 서면 제출
|
|
- 전자우편: 회사 이메일로 요청
|
|
- 모사전송(FAX): 회사 FAX로 요청
|
|
③ 권리 행사는 정보주체의 법정대리인이나 위임을 받은 자를 통해 대리로도 가능합니다. 이 경우 “개인정보 처리 방법에 관한 고시” 별지 제11호 서식에 따른 위임장을 제출해야 합니다.
|
|
④ 개인정보 열람 및 처리정지 요구는 「개인정보 보호법」 제35조 제4항, 제37조 제2항에 따라 제한될 수 있습니다.
|
|
⑤ 개인정보의 정정 및 삭제 요구는 다른 법령에 따라 수집된 개인정보인 경우 제한될 수 있습니다.
|
|
⑥ 회사는 권리 행사를 요청한 자가 본인 또는 정당한 대리인인지를 확인합니다.
|
|
제7조 (처리하는 개인정보의 항목)
|
|
회사는 다음의 개인정보 항목을 처리합니다:
|
|
- 수집 항목:
|
|
- 필수 항목: 성명, 휴대전화번호, 이메일
|
|
- 선택 항목: 회사전화번호, 문의사항
|
|
- 수집 방법:
|
|
- 홈페이지, 전화, 이메일을 통해 수집
|
|
제8조 (개인정보의 파기)
|
|
① 회사는 개인정보 보유 기간의 경과, 처리 목적 달성 등 개인정보가 불필요하게 되었을 때 지체 없이 해당 개인정보를 파기합니다.
|
|
② 정보주체로부터 동의받은 개인정보 보유 기간이 경과하거나 처리 목적이 달성된 경우에도 다른 법령에 따라 개인정보를 계속 보존해야 할 경우에는, 해당 개인정보를 별도의 데이터베이스(DB)로 옮기거나 보관 장소를 달리하여 보존합니다.
|
|
③ 개인정보 파기의 절차 및 방법은 다음과 같습니다:
|
|
- 파기 절차: 회사는 파기 사유가 발생한 개인정보를 선정하고, 개인정보 보호책임자의 승인을 받아 개인정보를 파기합니다.
|
|
- 파기 방법: 전자적 파일 형태로 기록된 개인정보는 복구할 수 없도록 기술적 방법을 사용해 삭제하며, 종이 문서에 기록된 개인정보는 분쇄기로 분쇄하거나 소각하여 파기합니다.
|
|
제9조 (개인정보의 안전성 확보 조치)
|
|
회사는 개인정보의 안전성 확보를 위해 다음과 같은 조치를 취합니다:
|
|
- 관리적 조치: 내부관리계획 수립·시행, 정기적 직원 교육
|
|
- 기술적 조치: 개인정보처리시스템 접근 권한 관리, 접근통제시스템 설치, 고유식별정보 암호화, 보안 프로그램 설치
|
|
- 물리적 조치: 전산실 및 자료보관실 접근 통제
|
|
제10조 (개인정보 자동 수집 장치의 설치·운영 및 거부에 관한 사항)
|
|
회사는 쿠키(Cookie)를 사용하지 않습니다. 쿠키는 이용자의 이용 정보를 저장하고 수시로 불러오는 작은 파일로, 바론서비스에서는 쿠키를 사용하지 않습니다.
|
|
제11조 (개인정보 보호책임자)
|
|
회사는 개인정보 처리에 관한 업무를 총괄하여 책임지고, 개인정보 처리와 관련된 정보주체의 불만처리 및 피해구제를 위해 개인정보 보호책임자를 지정하고 있습니다.
|
|
개인정보 보호책임자:
|
|
- 성명: 염승호
|
|
- 직책: 수석연구원
|
|
- 연락처: 02-2141-7448
|
|
- 팩스번호: 02-2141-7599
|
|
- 이메일: b23008@baroncs.co.kr
|
|
제12조 (개인정보 열람청구)
|
|
정보주체는 「개인정보 보호법」 제35조에 따른 개인정보 열람 청구를 아래 부서에 할 수 있습니다. 회사는 정보주체의 개인정보 열람청구가 신속하게 처리되도록 노력하겠습니다.
|
|
개인정보 열람청구 접수·처리 부서:
|
|
- 부서명: 총괄기획실
|
|
- 담당자: 권혁진
|
|
- 연락처: 02-2141-7465
|
|
- 팩스번호: 02-2141-7599
|
|
- 이메일: baroncs@baroncs.co.kr
|
|
제13조 (권익침해 구제방법)
|
|
정보주체는 개인정보 침해로 인한 구제를 위해 개인정보분쟁조정위원회, 한국인터넷진흥원 개인정보해신고센터 등에 분쟁 해결이나 상담을 신청할 수 있습니다.
|
|
- 개인정보분쟁조정위원회: (국번없이) 1833-6972 (www.kopico.go.kr)
|
|
- 개인정보침해신고센터: (국번없이) 118 (privacy.kisa.or.kr)
|
|
- 대검찰청: (국번없이) 1301 (www.spo.go.kr)
|
|
- 경찰청: (국번없이) 182 (www.police.go.kr)
|
|
제14조 (개인정보 처리방침의 변경)
|
|
본 개인정보처리방침은 법령, 정책 또는 보안 기술의 변경에 따라 내용의 추가, 삭제 및 수정이 있을 시, 개정 최소 7일 전에 홈페이지를 통해 사전 공지합니다.
|
|
|
|
부칙
|
|
제1조 (시행일자)
|
|
이 개인정보처리방침은 2024년 10월 1일부터 시행됩니다.
|
|
제2조 (개정 및 고지의 의무)
|
|
회사는 개인정보처리방침을 변경하는 경우, 변경사항을 시행일자 7일 전부터 서비스 내 공지사항 페이지를 통해 고지할 것입니다. 다만, 이용자의 권리나 의무에 중대한 변경이 발생하는 경우에는 시행일자 30일 전부터 고지합니다.
|
|
제3조 (유효성)
|
|
본 개인정보처리방침의 일부 조항이 법적 또는 기타 사유로 인해 무효화되거나 시행할 수 없는 경우, 나머지 조항들은 계속해서 유효합니다. 무효화된 조항은 관련 법령에 부합하는 방식으로 수정되어 효력을 지속합니다.
|
|
제4조 (변경 통지의 방법)
|
|
회사는 개인정보처리방침의 변경 시, 다음의 방법으로 이용자에게 고지합니다:
|
|
- 서비스 초기화면 또는 팝업 공지
|
|
- 이메일 발송
|
|
- 회사 홈페이지 공지사항
|
|
제5조 (비회원의 개인정보 보호)
|
|
회사는 비회원의 개인정보도 회원과 동일한 수준으로 보호합니다. 비회원이 개인정보 제공을 거부할 경우 일부 서비스 이용에 제한이 있을 수 있습니다.
|
|
제6조 (14세 미만 아동의 개인정보 보호)
|
|
회사는 14세 미만 아동의 개인정보를 수집하지 않습니다. 만일 14세 미만 아동의 개인정보가 수집된 경우, 법정 대리인의 동의를 받아야 하며, 법정 대리인의 동의 없이 수집된 경우 이를 지체 없이 파기합니다.
|
|
제7조 (개인정보의 국외 이전)
|
|
회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.
|
|
제8조 (기타)
|
|
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
|
""",
|
|
);
|
|
|
|
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),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
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,
|
|
),
|
|
),
|
|
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
|
|
? tr('ui.common.resend')
|
|
: tr('ui.userfront.signup.auth.request_code'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
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(),
|
|
),
|
|
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<String>(
|
|
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,
|
|
),
|
|
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;
|
|
});
|
|
},
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
// 가족사 선택 (가족사 메일일 경우 비활성화)
|
|
if (_affiliationType == 'AFFILIATE') ...[
|
|
AbsorbPointer(
|
|
absorbing: _isAffiliateEmail,
|
|
child: Opacity(
|
|
opacity: _isAffiliateEmail ? 0.7 : 1.0,
|
|
child: DropdownButtonFormField<String>(
|
|
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: 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(),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
|
|
String _buildPolicyDescription() {
|
|
if (_isPolicyLoading) {
|
|
return tr('msg.userfront.signup.policy.loading');
|
|
}
|
|
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
|
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
|
final requiresLower = _policy?['lowercase'] ?? true;
|
|
final requiresUpper = _policy?['uppercase'] ?? false;
|
|
final requiresNumber = _policy?['number'] ?? true;
|
|
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
|
|
|
|
final parts = <String>[
|
|
tr(
|
|
'msg.userfront.signup.policy.min_length',
|
|
params: {'count': minLength.toString()},
|
|
),
|
|
];
|
|
if (minTypes > 0) {
|
|
parts.add(
|
|
tr(
|
|
'msg.userfront.signup.policy.min_types',
|
|
params: {'count': minTypes.toString()},
|
|
),
|
|
);
|
|
}
|
|
if (requiresUpper) {
|
|
parts.add(tr('msg.userfront.signup.policy.uppercase'));
|
|
}
|
|
if (requiresLower) {
|
|
parts.add(tr('msg.userfront.signup.policy.lowercase'));
|
|
}
|
|
if (requiresNumber) {
|
|
parts.add(tr('msg.userfront.signup.policy.number'));
|
|
}
|
|
if (requiresSymbol) {
|
|
parts.add(tr('msg.userfront.signup.policy.symbol'));
|
|
}
|
|
|
|
return tr(
|
|
'msg.userfront.signup.policy.summary',
|
|
params: {'rules': parts.join(', ')},
|
|
);
|
|
}
|
|
|
|
Widget _buildStepPassword() {
|
|
String p = _passwordController.text;
|
|
|
|
// Default Policy Fallback
|
|
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
|
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
|
final requiresLower = _policy?['lowercase'] ?? true;
|
|
final requiresUpper = _policy?['uppercase'] ?? false;
|
|
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'[!@#$%^&*(),.?":{}|<>]'));
|
|
int typeCount = 0;
|
|
if (hasUpper) typeCount++;
|
|
if (hasLower) typeCount++;
|
|
if (hasDigit) typeCount++;
|
|
if (hasSpecial) typeCount++;
|
|
bool hasTypeCount = minTypes <= 0 || typeCount >= minTypes;
|
|
|
|
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,
|
|
),
|
|
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,
|
|
),
|
|
],
|
|
),
|
|
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 _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(
|
|
tr('ui.userfront.signup.title'),
|
|
style: const TextStyle(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: Text(
|
|
tr('ui.common.prev'),
|
|
style: const 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
|
|
? tr('ui.userfront.signup.next_step')
|
|
: tr('ui.userfront.signup.complete'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|