forked from baron/baron-sso
2176 lines
86 KiB
Dart
2176 lines
86 KiB
Dart
import 'dart:math' as math;
|
|
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';
|
|
import '../../../core/ui/toast_service.dart';
|
|
|
|
class SignupScreen extends StatefulWidget {
|
|
const SignupScreen({super.key});
|
|
|
|
@override
|
|
State<SignupScreen> createState() => _SignupScreenState();
|
|
}
|
|
|
|
class _SignupScreenState extends State<SignupScreen> {
|
|
static const _signupInk = Color(0xFF111827);
|
|
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 = 1024.0;
|
|
static const _agreementCardMaxWidth = 880.0;
|
|
static const _stepIndicatorDesktopBreakpoint = 1024.0;
|
|
static const _stepIndicatorMaxWidth = 880.0;
|
|
|
|
final _formKey = GlobalKey<FormState>();
|
|
int _currentStep = 1;
|
|
|
|
// Controllers
|
|
final _emailController = TextEditingController();
|
|
final _emailCodeController = TextEditingController(); // [Restore]
|
|
final _nameController = TextEditingController();
|
|
final _passwordController = TextEditingController();
|
|
final _confirmPasswordController = TextEditingController();
|
|
final _phoneController = TextEditingController();
|
|
final _phoneCodeController = TextEditingController(); // [Restore]
|
|
final _deptController = TextEditingController();
|
|
|
|
// State
|
|
bool _isEmailVerified = false;
|
|
bool _isPhoneVerified = false;
|
|
String _affiliationType = 'GENERAL';
|
|
bool _isAffiliateLocked = false;
|
|
String? _companyCode;
|
|
bool _termsAccepted = false;
|
|
bool _privacyAccepted = false;
|
|
bool _isLoading = false;
|
|
Map<String, dynamic>? _policy;
|
|
bool _isPolicyLoading = false;
|
|
bool _isPasswordObscured = true;
|
|
bool _isConfirmPasswordObscured = true;
|
|
|
|
// Dynamic Tenants
|
|
List<Map<String, dynamic>> _tenants = [];
|
|
|
|
// Inline Errors
|
|
String? _emailError;
|
|
String? _phoneError;
|
|
String? _passwordError;
|
|
String? _confirmPasswordError;
|
|
|
|
// Timers
|
|
Timer? _emailTimer;
|
|
int _emailSeconds = 0;
|
|
Timer? _phoneTimer;
|
|
int _phoneSeconds = 0;
|
|
|
|
String _renderTranslatedText(
|
|
String key, {
|
|
String? fallback,
|
|
Map<String, String> values = const {},
|
|
}) {
|
|
var text = tr(key, fallback: fallback);
|
|
values.forEach((name, value) {
|
|
text = text.replaceAll('{{$name}}', value).replaceAll('{$name}', value);
|
|
});
|
|
return text;
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_loadPolicy();
|
|
// initState에서는 _fetchTenants() 호출 제외
|
|
}
|
|
|
|
Future<void> _fetchTenants() async {
|
|
if (!_isEmailVerified) return;
|
|
|
|
try {
|
|
final tenants = await AuthProxyService.getActiveTenants(
|
|
email: _emailController.text.trim(),
|
|
);
|
|
if (mounted) {
|
|
setState(() {
|
|
_tenants = tenants;
|
|
if (_tenants.isNotEmpty && _affiliationType == 'AFFILIATE') {
|
|
// 목록이 있는데 아직 아무것도 선택되지 않았다면 자동 할당 가능
|
|
_companyCode ??= _tenants.first['slug'];
|
|
}
|
|
});
|
|
}
|
|
} catch (e) {
|
|
debugPrint('Failed to load tenants: $e');
|
|
}
|
|
}
|
|
|
|
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) {
|
|
// Note: We no longer auto-set _companyCode or _affiliationType based on domain
|
|
// as per user requirement (same domain can belong to different affiliates).
|
|
return;
|
|
}
|
|
|
|
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 res = await AuthProxyService.verifySignupCode(
|
|
_emailController.text.trim(),
|
|
'email',
|
|
code,
|
|
);
|
|
if (res['success'] == true) {
|
|
setState(() {
|
|
_isEmailVerified = true;
|
|
_emailTimer?.cancel();
|
|
_emailSeconds = 0;
|
|
_emailError = null;
|
|
|
|
if (res['isAffiliate'] == true) {
|
|
_affiliationType = 'AFFILIATE';
|
|
_isAffiliateLocked = true;
|
|
} else {
|
|
_affiliationType = 'GENERAL';
|
|
_companyCode = null;
|
|
_isAffiliateLocked = true;
|
|
}
|
|
});
|
|
|
|
// Only fetch tenants if it's an affiliate domain
|
|
if (res['isAffiliate'] == true) {
|
|
_fetchTenants();
|
|
}
|
|
} 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 res = await AuthProxyService.verifySignupCode(
|
|
_phoneController.text.trim(),
|
|
'phone',
|
|
code,
|
|
);
|
|
if (res['success'] == true) {
|
|
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(),
|
|
termsAccepted: true,
|
|
);
|
|
if (mounted) _showSuccessDialog();
|
|
} catch (e) {
|
|
String eStr = e.toString().toLowerCase();
|
|
setState(() {
|
|
if (eStr.contains('password') && eStr.contains('uppercase')) {
|
|
_passwordError = tr(
|
|
'msg.userfront.signup.password.uppercase_required',
|
|
);
|
|
} else if (eStr.contains('password') && eStr.contains('lowercase')) {
|
|
_passwordError = tr(
|
|
'msg.userfront.signup.password.lowercase_required',
|
|
);
|
|
} else if (eStr.contains('password') &&
|
|
(eStr.contains('digit') || eStr.contains('number'))) {
|
|
_passwordError = tr('msg.userfront.signup.password.number_required');
|
|
} else if (eStr.contains('password') &&
|
|
(eStr.contains('symbol') || eStr.contains('special'))) {
|
|
_passwordError = tr('msg.userfront.signup.password.symbol_required');
|
|
} else if (eStr.contains('password') &&
|
|
(eStr.contains('length') || eStr.contains('12 characters'))) {
|
|
_passwordError = tr('msg.userfront.signup.password.length_required');
|
|
} else {
|
|
_showError(
|
|
tr(
|
|
'msg.userfront.signup.failed',
|
|
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
|
),
|
|
);
|
|
}
|
|
});
|
|
} finally {
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
void _showError(String message) {
|
|
if (!mounted) return;
|
|
ToastService.error(message);
|
|
}
|
|
|
|
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: () {
|
|
Navigator.of(context).pop();
|
|
context.go(buildLocalizedSigninPath(Uri.base));
|
|
},
|
|
child: Text(tr('ui.userfront.signup.success.action')),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// --- UI Components ---
|
|
|
|
Widget _buildStepIndicator() {
|
|
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, {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: [
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
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, {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() {
|
|
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.agreement.title'),
|
|
style: TextStyle(
|
|
fontSize: isDesktop ? 28 : 20,
|
|
fontWeight: FontWeight.w700,
|
|
height: 1.25,
|
|
color: _signupInk,
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
tr('msg.userfront.signup.agreement.description'),
|
|
style: TextStyle(
|
|
fontSize: isDesktop ? 15 : 14,
|
|
height: 1.6,
|
|
color: _signupMuted,
|
|
),
|
|
),
|
|
SizedBox(height: isDesktop ? 28 : 24),
|
|
_buildAgreementSummaryCard(isDesktop: isDesktop),
|
|
const SizedBox(height: 18),
|
|
_agreementSection(
|
|
title: tr('ui.userfront.signup.agreement.tos_title'),
|
|
summary: tr('msg.userfront.signup.agreement.tos_summary'),
|
|
content: _tosText,
|
|
value: _termsAccepted,
|
|
isDesktop: isDesktop,
|
|
onChanged: (val) =>
|
|
setState(() => _termsAccepted = val ?? false),
|
|
),
|
|
const SizedBox(height: 18),
|
|
_agreementSection(
|
|
title: tr('ui.userfront.signup.agreement.privacy_title'),
|
|
summary: tr(
|
|
'msg.userfront.signup.agreement.privacy_summary',
|
|
),
|
|
content: _privacyText,
|
|
value: _privacyAccepted,
|
|
isDesktop: isDesktop,
|
|
onChanged: (val) =>
|
|
setState(() => _privacyAccepted = val ?? false),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildAgreementSummaryCard({required bool isDesktop}) {
|
|
final acceptedCount = [
|
|
_termsAccepted,
|
|
_privacyAccepted,
|
|
].where((accepted) => accepted).length;
|
|
|
|
return DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: _signupSurface,
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: _signupBorder),
|
|
),
|
|
child: Padding(
|
|
padding: EdgeInsets.all(isDesktop ? 20 : 16),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
CheckboxListTile(
|
|
title: Text(
|
|
tr('ui.userfront.signup.agreement.all'),
|
|
style: TextStyle(
|
|
fontSize: isDesktop ? 17 : 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: _signupInk,
|
|
),
|
|
),
|
|
subtitle: Padding(
|
|
padding: const EdgeInsets.only(top: 4),
|
|
child: Text(
|
|
tr('msg.userfront.signup.agreement.all_hint'),
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
height: 1.45,
|
|
color: _signupMuted,
|
|
),
|
|
),
|
|
),
|
|
value: _termsAccepted && _privacyAccepted,
|
|
onChanged: (val) {
|
|
setState(() {
|
|
final next = val ?? false;
|
|
_termsAccepted = next;
|
|
_privacyAccepted = next;
|
|
});
|
|
},
|
|
contentPadding: EdgeInsets.zero,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
activeColor: _signupInk,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
tr(
|
|
'msg.userfront.signup.agreement.progress',
|
|
params: {'count': '$acceptedCount', 'total': '2'},
|
|
),
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
color: _signupMuted,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _agreementSection({
|
|
required String title,
|
|
required String summary,
|
|
required String content,
|
|
required bool value,
|
|
required bool isDesktop,
|
|
required ValueChanged<bool?> onChanged,
|
|
}) {
|
|
final contentHeight = isDesktop ? 260.0 : 148.0;
|
|
|
|
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: CheckboxListTile(
|
|
title: Text(
|
|
title,
|
|
style: TextStyle(
|
|
fontSize: isDesktop ? 16 : 14,
|
|
fontWeight: FontWeight.w700,
|
|
color: _signupInk,
|
|
),
|
|
),
|
|
subtitle: Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Text(
|
|
summary,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
height: 1.45,
|
|
color: _signupMuted,
|
|
),
|
|
),
|
|
),
|
|
value: value,
|
|
onChanged: onChanged,
|
|
controlAffinity: ListTileControlAffinity.leading,
|
|
contentPadding: EdgeInsets.zero,
|
|
activeColor: _signupInk,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
_buildRequiredBadge(),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
height: contentHeight,
|
|
width: double.infinity,
|
|
padding: EdgeInsets.all(isDesktop ? 18 : 14),
|
|
decoration: BoxDecoration(
|
|
color: _signupSurface,
|
|
border: Border.all(color: _signupBorder),
|
|
borderRadius: BorderRadius.circular(14),
|
|
),
|
|
child: SingleChildScrollView(
|
|
child: SelectableText(
|
|
content,
|
|
style: TextStyle(
|
|
fontSize: isDesktop ? 13 : 12,
|
|
color: _signupMuted,
|
|
height: 1.6,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildRequiredBadge() {
|
|
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: Text(
|
|
tr('ui.userfront.signup.agreement.required'),
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w700,
|
|
color: Color(0xFF3730A3),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStepBody() {
|
|
final stepChild = _currentStep == 1
|
|
? _buildStepAgreement()
|
|
: (_currentStep == 2
|
|
? _buildStepAuth()
|
|
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword()));
|
|
|
|
return LayoutBuilder(
|
|
builder: (context, constraints) {
|
|
final isAgreementStep = _currentStep == 1;
|
|
final horizontalPadding =
|
|
isAgreementStep &&
|
|
constraints.maxWidth >= _agreementDesktopBreakpoint
|
|
? 40.0
|
|
: 24.0;
|
|
|
|
return SingleChildScrollView(
|
|
padding: EdgeInsets.fromLTRB(
|
|
horizontalPadding,
|
|
24,
|
|
horizontalPadding,
|
|
32,
|
|
),
|
|
child: Form(key: _formKey, child: stepChild),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
static String _resolveAgreementText(
|
|
String key, {
|
|
required String fallback,
|
|
required Set<String> placeholders,
|
|
}) {
|
|
final localized = tr(key, fallback: '').trim();
|
|
if (localized.isEmpty || placeholders.contains(localized)) {
|
|
return fallback;
|
|
}
|
|
return localized;
|
|
}
|
|
|
|
static String get _tosText {
|
|
const 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일부터 시행됩니다.
|
|
""";
|
|
return _resolveAgreementText(
|
|
'msg.userfront.signup.tos_full',
|
|
fallback: fallback,
|
|
placeholders: {'서비스 이용약관 전문...', 'Tos Full'},
|
|
);
|
|
}
|
|
|
|
static String get _privacyText {
|
|
const 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조 (기타)
|
|
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
|
""";
|
|
return _resolveAgreementText(
|
|
'msg.userfront.signup.privacy_full',
|
|
fallback: fallback,
|
|
placeholders: {'개인정보 수집 및 이용 동의 전문...', 'Privacy Full'},
|
|
);
|
|
}
|
|
|
|
Widget _buildStepAuth() {
|
|
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();
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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<void> 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<String>? onChanged,
|
|
ValueChanged<String>? 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,
|
|
),
|
|
),
|
|
),
|
|
if (verified) _buildVerifiedBadge(verifiedText),
|
|
],
|
|
),
|
|
SizedBox(height: isDesktop ? 20 : 16),
|
|
isDesktop
|
|
? 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,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
SizedBox(
|
|
height: 52,
|
|
width: 108,
|
|
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,
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
)
|
|
: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
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,
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
SizedBox(
|
|
height: 52,
|
|
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,
|
|
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.bold),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOut,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (verificationVisible) ...[
|
|
const SizedBox(height: 12),
|
|
isDesktop
|
|
? 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),
|
|
const SizedBox(width: 108),
|
|
],
|
|
)
|
|
: 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,
|
|
),
|
|
),
|
|
],
|
|
if (verified) ...[
|
|
const SizedBox(height: 12),
|
|
_buildVerificationStatus(verifiedText),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildStepInfo() {
|
|
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.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: '소속 유형과 회사 정보를 입력합니다.',
|
|
isDesktop: isDesktop,
|
|
trailing: null,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
DropdownButtonFormField<String>(
|
|
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: _isAffiliateLocked
|
|
? null
|
|
: (val) {
|
|
if (val == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_affiliationType = val;
|
|
if (_affiliationType == 'GENERAL') {
|
|
_companyCode = null;
|
|
}
|
|
});
|
|
},
|
|
),
|
|
AnimatedSize(
|
|
duration: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOut,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
if (_affiliationType == 'AFFILIATE') ...[
|
|
const SizedBox(height: 14),
|
|
DropdownButtonFormField<String>(
|
|
key: ValueKey(_companyCode ?? 'none'),
|
|
initialValue: _companyCode,
|
|
decoration: InputDecoration(
|
|
labelText: tr(
|
|
'ui.userfront.signup.profile.company',
|
|
),
|
|
border: const OutlineInputBorder(),
|
|
),
|
|
items: _tenants.map((t) {
|
|
return DropdownMenuItem<String>(
|
|
value: t['slug'],
|
|
child: Text(t['name'] ?? t['slug']),
|
|
);
|
|
}).toList(),
|
|
onChanged: (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(),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Widget _buildProfileInfoNoticeCard({required bool isDesktop}) {
|
|
const description = '회원가입 후 사용할 기본 소속 정보를 입력합니다.';
|
|
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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],
|
|
],
|
|
),
|
|
SizedBox(height: isDesktop ? 18 : 14),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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>[
|
|
_renderTranslatedText(
|
|
'msg.userfront.signup.policy.min_length',
|
|
values: {'count': minLength.toString()},
|
|
),
|
|
];
|
|
if (minTypes > 0) {
|
|
parts.add(
|
|
_renderTranslatedText(
|
|
'msg.userfront.signup.policy.min_types',
|
|
values: {'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 _renderTranslatedText(
|
|
'msg.userfront.signup.policy.summary',
|
|
values: {'rules': parts.join(', ')},
|
|
);
|
|
}
|
|
|
|
Widget _buildStepPassword() {
|
|
final 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;
|
|
|
|
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++;
|
|
final hasTypeCount = minTypes <= 0 || typeCount >= minTypes;
|
|
final passwordChecks = <Widget>[
|
|
_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 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.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,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
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),
|
|
),
|
|
child: const Icon(
|
|
Icons.security_rounded,
|
|
size: 18,
|
|
color: Color(0xFF1D4ED8),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
_buildPolicyDescription(),
|
|
style: TextStyle(
|
|
fontSize: isDesktop ? 14 : 12,
|
|
height: 1.4,
|
|
color: const Color(0xFF1E3A8A),
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
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<Widget> 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),
|
|
),
|
|
);
|
|
}
|
|
|
|
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: _buildStepBody()),
|
|
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'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|