forked from baron/baron-sso
린트 적용
This commit is contained in:
@@ -134,13 +134,16 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
style: TextStyle(color: Colors.grey.shade600),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
|
||||
|
||||
if (_message != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
_message!,
|
||||
style: TextStyle(color: _success ? Colors.green : Colors.red, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
color: _success ? Colors.green : Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
@@ -155,7 +158,7 @@ class _ApproveQrScreenState extends State<ApproveQrScreen> {
|
||||
backgroundColor: Colors.blue,
|
||||
),
|
||||
),
|
||||
|
||||
|
||||
if (!isLoggedIn && !_success)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
|
||||
@@ -17,7 +17,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
bool _isLoading = true;
|
||||
bool _isSubmitting = false;
|
||||
String? _error;
|
||||
|
||||
|
||||
// 사용자가 선택한 스코프 목록
|
||||
final Set<String> _selectedScopes = {};
|
||||
|
||||
@@ -41,8 +41,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
|
||||
Future<void> _fetchConsentInfo() async {
|
||||
try {
|
||||
final info = await AuthProxyService.getConsentInfo(widget.consentChallenge);
|
||||
|
||||
final info = await AuthProxyService.getConsentInfo(
|
||||
widget.consentChallenge,
|
||||
);
|
||||
|
||||
// [Skip Logic] 백엔드에서 자동 승인되어 리다이렉트 URL이 온 경우 즉시 이동
|
||||
if (info['redirectTo'] != null) {
|
||||
webWindow.redirectTo(info['redirectTo']);
|
||||
@@ -52,19 +54,20 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
// 백엔드에서 전달받은 커스텀 스코프 정보(scope_details) 적용
|
||||
if (info['scope_details'] != null) {
|
||||
final details = info['scope_details'] as Map<String, dynamic>;
|
||||
|
||||
|
||||
details.forEach((scope, detail) {
|
||||
if (detail is Map<String, dynamic>) {
|
||||
// 설명 업데이트
|
||||
if (detail['description'] != null && detail['description'].toString().isNotEmpty) {
|
||||
if (detail['description'] != null &&
|
||||
detail['description'].toString().isNotEmpty) {
|
||||
_scopeDescriptions[scope] = detail['description'].toString();
|
||||
}
|
||||
// 필수 여부 업데이트
|
||||
if (detail['mandatory'] == true) {
|
||||
_mandatoryScopes.add(scope);
|
||||
} else {
|
||||
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
|
||||
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
|
||||
// openid는 기본적으로 필수지만 설정에서 굳이 껐다면?
|
||||
// 안전을 위해 openid는 항상 필수로 유지하는 것이 좋지만,
|
||||
// 여기서는 서버 설정을 존중하되 openid는 예외처리 할 수도 있음.
|
||||
// 우선 서버 설정이 있으면 반영 (단, openid는 제거하지 않음)
|
||||
if (scope != 'openid') {
|
||||
@@ -76,7 +79,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
}
|
||||
|
||||
// 초기 선택 상태 설정: 모든 요청된 스코프를 기본 선택
|
||||
final requestedScopes = (info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
final requestedScopes =
|
||||
(info['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
_selectedScopes.addAll(requestedScopes);
|
||||
|
||||
setState(() {
|
||||
@@ -102,7 +106,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
widget.consentChallenge,
|
||||
grantScope: _selectedScopes.toList(),
|
||||
);
|
||||
|
||||
|
||||
if (result['redirectTo'] != null) {
|
||||
webWindow.redirectTo(result['redirectTo']);
|
||||
} else {
|
||||
@@ -142,7 +146,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
if (confirmed == true) {
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
final resp = await AuthProxyService.rejectConsent(widget.consentChallenge);
|
||||
final resp = await AuthProxyService.rejectConsent(
|
||||
widget.consentChallenge,
|
||||
);
|
||||
final redirectTo = resp['redirectTo'];
|
||||
if (redirectTo != null) {
|
||||
webWindow.redirectTo(redirectTo);
|
||||
@@ -152,9 +158,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
} catch (e) {
|
||||
setState(() => _isSubmitting = false);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('취소 처리 중 오류가 발생했습니다: $e')));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,13 +170,13 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
// 배경색을 약간 어둡게 처리하거나, 전체적인 테마 색상을 사용
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.grey[100],
|
||||
backgroundColor: Colors.grey[100],
|
||||
body: Center(
|
||||
child: _isLoading
|
||||
? const CircularProgressIndicator()
|
||||
: _error != null
|
||||
? _buildErrorCard()
|
||||
: _buildConsentCard(context),
|
||||
? _buildErrorCard()
|
||||
: _buildConsentCard(context),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -196,7 +202,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
final clientName = _consentInfo?['client']?['client_name'] ?? '알 수 없는 앱';
|
||||
final clientId = _consentInfo?['client']?['client_id'] ?? '-';
|
||||
final clientLogo = _consentInfo?['client']?['logo_uri'];
|
||||
final requestedScopes = (_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ?? [];
|
||||
final requestedScopes =
|
||||
(_consentInfo?['requested_scope'] as List<dynamic>?)?.cast<String>() ??
|
||||
[];
|
||||
|
||||
return SingleChildScrollView(
|
||||
child: Container(
|
||||
@@ -204,7 +212,9 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32.0),
|
||||
child: Column(
|
||||
@@ -235,7 +245,8 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (clientLogo != null && clientLogo.toString().isNotEmpty)
|
||||
if (clientLogo != null &&
|
||||
clientLogo.toString().isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 16),
|
||||
child: CircleAvatar(
|
||||
@@ -286,7 +297,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
children: [
|
||||
const Text(
|
||||
'요청된 권한',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'총 ${requestedScopes.length}개',
|
||||
@@ -354,7 +368,10 @@ class _ConsentScreenState extends State<ConsentScreen> {
|
||||
)
|
||||
: const Text(
|
||||
'동의하고 계속하기',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
@@ -32,30 +32,36 @@ class ErrorScreen extends StatelessWidget {
|
||||
final title = isProd
|
||||
? tr('msg.userfront.error.title', fallback: '인증 과정에서 오류가 발생했습니다')
|
||||
: (hasCode
|
||||
? tr(
|
||||
'msg.userfront.error.title_with_code',
|
||||
fallback: '오류: {{code}}',
|
||||
params: {'code': normalizedCode},
|
||||
)
|
||||
: tr('msg.userfront.error.title_generic', fallback: '오류가 발생했습니다'));
|
||||
? tr(
|
||||
'msg.userfront.error.title_with_code',
|
||||
fallback: '오류: {{code}}',
|
||||
params: {'code': normalizedCode},
|
||||
)
|
||||
: tr(
|
||||
'msg.userfront.error.title_generic',
|
||||
fallback: '오류가 발생했습니다',
|
||||
));
|
||||
final detail = isProd
|
||||
? (isWhitelisted
|
||||
? tr(
|
||||
'msg.userfront.error.whitelist.$normalizedCode',
|
||||
fallback: whitelistFallback,
|
||||
)
|
||||
: tr(
|
||||
'msg.userfront.error.detail_contact',
|
||||
fallback: '에러가 계속되면 관리자에게 문의해주세요',
|
||||
))
|
||||
? tr(
|
||||
'msg.userfront.error.whitelist.$normalizedCode',
|
||||
fallback: whitelistFallback,
|
||||
)
|
||||
: tr(
|
||||
'msg.userfront.error.detail_contact',
|
||||
fallback: '에러가 계속되면 관리자에게 문의해주세요',
|
||||
))
|
||||
: ((description?.isNotEmpty == true)
|
||||
? description!
|
||||
: (hasCode
|
||||
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
|
||||
: tr(
|
||||
'msg.userfront.error.detail_request',
|
||||
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
|
||||
)));
|
||||
? description!
|
||||
: (hasCode
|
||||
? tr(
|
||||
'msg.userfront.error.detail_generic',
|
||||
fallback: '오류가 발생했습니다.',
|
||||
)
|
||||
: tr(
|
||||
'msg.userfront.error.detail_request',
|
||||
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
|
||||
)));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF7F8FA),
|
||||
@@ -124,20 +130,29 @@ class ErrorScreen extends StatelessWidget {
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF111827),
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
|
||||
tr(
|
||||
'ui.userfront.error.go_login',
|
||||
fallback: '로그인으로 이동',
|
||||
),
|
||||
),
|
||||
),
|
||||
OutlinedButton(
|
||||
onPressed: () => context.go('/'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF111827),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
side: const BorderSide(color: Color(0xFFCBD5F5)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
|
||||
@@ -17,7 +17,9 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
|
||||
_drySendEnabled =
|
||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||
!AuthProxyService.isProdEnv;
|
||||
}
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
@@ -44,7 +46,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
|
||||
await AuthProxyService.initiatePasswordReset(
|
||||
loginId,
|
||||
drySend: _drySendEnabled,
|
||||
);
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -107,16 +112,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
children: [
|
||||
Text(
|
||||
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
|
||||
style: TextStyle(
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (_drySendEnabled) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3CD),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -124,7 +129,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Color(0xFF8A6D3B),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -132,7 +140,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
'msg.userfront.forgot.dry_send',
|
||||
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
||||
),
|
||||
style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8A6D3B),
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -172,13 +183,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
tr(
|
||||
'ui.userfront.forgot.submit',
|
||||
fallback: '재설정 링크 전송',
|
||||
),
|
||||
tr('ui.userfront.forgot.submit', fallback: '재설정 링크 전송'),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -18,7 +18,12 @@ class LoginScreen extends ConsumerStatefulWidget {
|
||||
final String? loginChallenge;
|
||||
final String? redirectUrl;
|
||||
|
||||
const LoginScreen({super.key, this.verificationToken, this.loginChallenge, this.redirectUrl});
|
||||
const LoginScreen({
|
||||
super.key,
|
||||
this.verificationToken,
|
||||
this.loginChallenge,
|
||||
this.redirectUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<LoginScreen> createState() => _LoginScreenState();
|
||||
@@ -28,7 +33,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late TabController _tabController;
|
||||
final TextEditingController _linkIdController = TextEditingController();
|
||||
final TextEditingController _passwordLoginIdController = TextEditingController();
|
||||
final TextEditingController _passwordLoginIdController =
|
||||
TextEditingController();
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
String? _redirectUrl;
|
||||
String? _loginChallenge;
|
||||
@@ -41,8 +47,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
int _qrRemainingSeconds = 0;
|
||||
Timer? _qrCountdownTimer;
|
||||
int _qrPollIntervalMs = 2000;
|
||||
final TextEditingController _shortCodePrefixController = TextEditingController();
|
||||
final TextEditingController _shortCodeDigitsController = TextEditingController();
|
||||
final TextEditingController _shortCodePrefixController =
|
||||
TextEditingController();
|
||||
final TextEditingController _shortCodeDigitsController =
|
||||
TextEditingController();
|
||||
String? _linkPendingRef;
|
||||
String? _lastLinkLoginId;
|
||||
bool _lastLinkIsEmail = true;
|
||||
@@ -75,12 +83,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
super.initState();
|
||||
_tabController = TabController(length: 3, vsync: this, initialIndex: 1);
|
||||
_tabController.addListener(_handleTabSelection);
|
||||
_drySendEnabled = _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv;
|
||||
_drySendEnabled =
|
||||
_parseBoolParam(Uri.base.queryParameters['drySend']) &&
|
||||
!AuthProxyService.isProdEnv;
|
||||
_redirectUrl = widget.redirectUrl;
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final uri = Uri.base;
|
||||
|
||||
|
||||
if (_redirectUrl == null) {
|
||||
if (uri.queryParameters.containsKey('redirect_url')) {
|
||||
_redirectUrl = uri.queryParameters['redirect_url'];
|
||||
@@ -89,15 +99,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
_loginChallenge = widget.loginChallenge ?? uri.queryParameters['login_challenge'];
|
||||
_loginChallenge =
|
||||
widget.loginChallenge ?? uri.queryParameters['login_challenge'];
|
||||
final loginIdParam = uri.queryParameters['loginId'];
|
||||
final codeParam = uri.queryParameters['code'];
|
||||
final pendingRefParam = uri.queryParameters['pendingRef'];
|
||||
final hasShortCodePath = uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
||||
final hasShortCodePath =
|
||||
uri.pathSegments.length >= 2 && uri.pathSegments.first == 'l';
|
||||
final hasTokenParam = uri.queryParameters.containsKey('t');
|
||||
final hasVerificationToken = widget.verificationToken != null || hasTokenParam;
|
||||
final hasVerificationToken =
|
||||
widget.verificationToken != null || hasTokenParam;
|
||||
final hasLoginCode = loginIdParam != null && codeParam != null;
|
||||
_verificationOnly = hasVerificationToken || hasLoginCode || hasShortCodePath;
|
||||
_verificationOnly =
|
||||
hasVerificationToken || hasLoginCode || hasShortCodePath;
|
||||
final notice = uri.queryParameters['notice'];
|
||||
|
||||
if (hasShortCodePath) {
|
||||
@@ -150,9 +164,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
tr(
|
||||
'msg.userfront.login.cookie_check_failed',
|
||||
fallback: '로그인 확인 실패: {{error}}',
|
||||
params: {
|
||||
'error': e.toString().replaceFirst('Exception: ', ''),
|
||||
},
|
||||
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -171,8 +183,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
final token = AuthTokenStore.getToken();
|
||||
if (token != null && token.isNotEmpty) {
|
||||
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
||||
debugPrint("[Auth] Cookie session with external integration. Notifying...");
|
||||
if (WebAuthIntegration.isPopup() ||
|
||||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
||||
debugPrint(
|
||||
"[Auth] Cookie session with external integration. Notifying...",
|
||||
);
|
||||
WebAuthIntegration.sendLoginSuccess(token);
|
||||
return;
|
||||
}
|
||||
@@ -200,7 +215,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
try {
|
||||
await AuthProxyService.checkCookieSession();
|
||||
AuthTokenStore.setCookieMode(provider: AuthTokenStore.getProvider() ?? 'ory');
|
||||
AuthTokenStore.setCookieMode(
|
||||
provider: AuthTokenStore.getProvider() ?? 'ory',
|
||||
);
|
||||
await _acceptOidcLoginAndRedirect();
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] OIDC auto-accept cookie check failed: $e");
|
||||
@@ -289,7 +306,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final parts = jwt.split('.');
|
||||
if (parts.length != 3) return 'User';
|
||||
|
||||
final payload = utf8.decode(base64Url.decode(base64Url.normalize(parts[1])));
|
||||
final payload = utf8.decode(
|
||||
base64Url.decode(base64Url.normalize(parts[1])),
|
||||
);
|
||||
final data = json.decode(payload);
|
||||
return data['name'] ?? data['email'] ?? data['sub'] ?? 'User';
|
||||
} catch (e) {
|
||||
@@ -360,65 +379,65 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
void _startQrPolling() {
|
||||
_qrPollingTimer?.cancel();
|
||||
_qrPollingTimer = Timer.periodic(Duration(milliseconds: _qrPollIntervalMs), (timer) async {
|
||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
_qrPollingTimer = Timer.periodic(
|
||||
Duration(milliseconds: _qrPollIntervalMs),
|
||||
(timer) async {
|
||||
if (_qrPendingRef == null || !mounted || _qrRemainingSeconds <= 0) {
|
||||
timer.cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||
if (res['error'] == 'slow_down') {
|
||||
final interval = res['interval'];
|
||||
if (interval is int && interval > 0) {
|
||||
final nextIntervalMs = interval * 1000;
|
||||
if (nextIntervalMs != _qrPollIntervalMs) {
|
||||
_qrPollIntervalMs = nextIntervalMs;
|
||||
try {
|
||||
final res = await AuthProxyService.pollQrStatus(_qrPendingRef!);
|
||||
if (res['error'] == 'slow_down') {
|
||||
final interval = res['interval'];
|
||||
if (interval is int && interval > 0) {
|
||||
final nextIntervalMs = interval * 1000;
|
||||
if (nextIntervalMs != _qrPollIntervalMs) {
|
||||
_qrPollIntervalMs = nextIntervalMs;
|
||||
timer.cancel();
|
||||
_startQrPolling();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
_qrPollIntervalMs += 500;
|
||||
timer.cancel();
|
||||
_startQrPolling();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
_qrPollIntervalMs += 500;
|
||||
timer.cancel();
|
||||
_startQrPolling();
|
||||
}
|
||||
if (res['error'] == 'authorization_pending') {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (res['error'] == 'authorization_pending') {
|
||||
return;
|
||||
}
|
||||
if (res['error'] == 'expired_token') {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.qr_expired',
|
||||
fallback: 'QR 세션이 만료되었습니다.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res['status'] == 'ok') {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
final token = res['sessionJwt'] ?? res['token'];
|
||||
if (token is String && token.isNotEmpty) {
|
||||
_completeLoginFromToken(token);
|
||||
} else {
|
||||
if (res['error'] == 'expired_token') {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.token_missing',
|
||||
fallback: '로그인 토큰을 확인할 수 없습니다.',
|
||||
),
|
||||
tr('msg.userfront.login.qr_expired', fallback: 'QR 세션이 만료되었습니다.'),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (res['status'] == 'ok') {
|
||||
timer.cancel();
|
||||
_qrCountdownTimer?.cancel();
|
||||
final token = res['sessionJwt'] ?? res['token'];
|
||||
if (token is String && token.isNotEmpty) {
|
||||
_completeLoginFromToken(token);
|
||||
} else {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.token_missing',
|
||||
fallback: '로그인 토큰을 확인할 수 없습니다.',
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[QR] Polling error: $e");
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[QR] Polling error: $e");
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _stopQrPolling() {
|
||||
@@ -486,21 +505,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
Duration redirectDelay = const Duration(seconds: 2),
|
||||
}) {
|
||||
if (!mounted) return;
|
||||
final resolvedTitle = title ??
|
||||
tr(
|
||||
'ui.userfront.login.verification.title',
|
||||
fallback: '승인 완료',
|
||||
);
|
||||
final resolvedPageTitle = pageTitle ??
|
||||
tr(
|
||||
'ui.userfront.login.verification.page_title',
|
||||
fallback: '로그인 승인',
|
||||
);
|
||||
final resolvedActionLabel = actionLabel ??
|
||||
tr(
|
||||
'ui.userfront.login.verification.action_label',
|
||||
fallback: '확인',
|
||||
);
|
||||
final resolvedTitle =
|
||||
title ?? tr('ui.userfront.login.verification.title', fallback: '승인 완료');
|
||||
final resolvedPageTitle =
|
||||
pageTitle ??
|
||||
tr('ui.userfront.login.verification.page_title', fallback: '로그인 승인');
|
||||
final resolvedActionLabel =
|
||||
actionLabel ??
|
||||
tr('ui.userfront.login.verification.action_label', fallback: '확인');
|
||||
setState(() {
|
||||
_verificationApproved = true;
|
||||
_verificationMessage = message;
|
||||
@@ -524,11 +536,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, color: Colors.green, size: 72),
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: Colors.green,
|
||||
size: 72,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_verificationTitle,
|
||||
style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.green),
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.green,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -544,7 +564,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const SizedBox(height: 24),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final hasLocalSession = AuthTokenStore.getToken() != null || AuthTokenStore.usesCookie();
|
||||
final hasLocalSession =
|
||||
AuthTokenStore.getToken() != null ||
|
||||
AuthTokenStore.usesCookie();
|
||||
final target = hasLocalSession ? '/' : '/signin';
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -586,10 +608,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
if (status == 'approved' || (jwt == null && _verificationOnly)) {
|
||||
if (mounted) {
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -602,18 +621,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
return;
|
||||
}
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
|
||||
@@ -629,9 +642,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
|
||||
Future<void> _verifyLoginCode(
|
||||
String loginId,
|
||||
String code, {
|
||||
String? pendingRef,
|
||||
}) async {
|
||||
final sanitizedLoginId = loginId.replaceAll(' ', '+');
|
||||
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
|
||||
debugPrint(
|
||||
"[Auth] Starting code verification for loginId: $sanitizedLoginId",
|
||||
);
|
||||
final approvedMessage = tr(
|
||||
'msg.userfront.login.verification.approved',
|
||||
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
|
||||
@@ -653,16 +672,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
final jwt = res['sessionJwt'] ?? res['token'];
|
||||
final status = res['status']?.toString();
|
||||
debugPrint("[Auth] Code verification successful for loginId: $sanitizedLoginId");
|
||||
debugPrint(
|
||||
"[Auth] Code verification successful for loginId: $sanitizedLoginId",
|
||||
);
|
||||
final hasLocalSession = await _hasValidLocalSession();
|
||||
final actionPath = hasLocalSession ? '/' : '/signin';
|
||||
|
||||
if (jwt == null && status == 'approved') {
|
||||
if (mounted) {
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -676,18 +694,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
return;
|
||||
}
|
||||
if (_verificationOnly) {
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
return;
|
||||
}
|
||||
_markVerificationApproved(
|
||||
linkLoginMessage,
|
||||
title: tr(
|
||||
'ui.userfront.login.link.title',
|
||||
fallback: '링크 로그인 완료',
|
||||
),
|
||||
title: tr('ui.userfront.login.link.title', fallback: '링크 로그인 완료'),
|
||||
pageTitle: tr(
|
||||
'ui.userfront.login.link.page_title',
|
||||
fallback: '링크 로그인',
|
||||
@@ -703,13 +715,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
|
||||
if (_verificationOnly && mounted) {
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
|
||||
debugPrint(
|
||||
"[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e",
|
||||
);
|
||||
if (mounted) {
|
||||
_showError(
|
||||
tr(
|
||||
@@ -747,10 +758,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
|
||||
if (jwt == null && status == 'approved') {
|
||||
if (mounted) {
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -764,10 +772,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
return;
|
||||
}
|
||||
if (_verificationOnly) {
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
return;
|
||||
}
|
||||
_completeLoginFromToken(jwt, provider: res['provider'] as String?);
|
||||
@@ -775,10 +780,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
|
||||
if (_verificationOnly && mounted) {
|
||||
_markVerificationApproved(
|
||||
approvedMessage,
|
||||
actionPath: actionPath,
|
||||
);
|
||||
_markVerificationApproved(approvedMessage, actionPath: actionPath);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint("[Auth] Short code verification FAILED. Error: $e");
|
||||
@@ -836,7 +838,11 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
);
|
||||
|
||||
try {
|
||||
final res = await AuthProxyService.loginWithPassword(loginId, password, loginChallenge: _loginChallenge);
|
||||
final res = await AuthProxyService.loginWithPassword(
|
||||
loginId,
|
||||
password,
|
||||
loginChallenge: _loginChallenge,
|
||||
);
|
||||
final jwt = res['sessionJwt'];
|
||||
final provider = res['provider'] as String?;
|
||||
final redirectTo = res['redirectTo'] as String?;
|
||||
@@ -860,9 +866,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
tr(
|
||||
'msg.userfront.login.password.failed',
|
||||
fallback: '로그인 실패: {{error}}',
|
||||
params: {
|
||||
'error': e.toString().replaceFirst('Exception: ', ''),
|
||||
},
|
||||
params: {'error': e.toString().replaceFirst('Exception: ', '')},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -900,13 +904,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startEnchantedFlow(String loginId, {required bool isEmail, bool codeOnly = false}) async {
|
||||
Future<void> _startEnchantedFlow(
|
||||
String loginId, {
|
||||
required bool isEmail,
|
||||
bool codeOnly = false,
|
||||
}) async {
|
||||
try {
|
||||
if (mounted) {
|
||||
showDialog(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (context) => const Center(child: CircularProgressIndicator()),
|
||||
builder: (context) =>
|
||||
const Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -921,7 +930,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
final interval = initResponse['interval'];
|
||||
final resendAfter = initResponse['resendAfter'];
|
||||
final expiresIn = initResponse['expiresIn'];
|
||||
debugPrint("[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider");
|
||||
debugPrint(
|
||||
"[Auth] Link Sent. PendingRef: $pendingRef, Mode: $mode, Provider: $provider",
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
@@ -974,7 +985,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pollForSession(String pendingRef, {Duration? initialInterval}) async {
|
||||
Future<void> _pollForSession(
|
||||
String pendingRef, {
|
||||
Duration? initialInterval,
|
||||
}) async {
|
||||
int attempts = 0;
|
||||
const maxAttempts = 60;
|
||||
var pollInterval = initialInterval ?? const Duration(seconds: 2);
|
||||
@@ -1047,10 +1061,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
|
||||
Navigator.of(context).pop();
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.link_timeout',
|
||||
fallback: '로그인 요청 시간이 초과되었습니다.',
|
||||
),
|
||||
tr('msg.userfront.login.link_timeout', fallback: '로그인 요청 시간이 초과되었습니다.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1138,10 +1149,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
}
|
||||
}
|
||||
|
||||
if (WebAuthIntegration.isPopup() || (_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
||||
debugPrint("[Auth] External integration detected (popup or redirect). Notifying...");
|
||||
if (WebAuthIntegration.isPopup() ||
|
||||
(_redirectUrl != null && _redirectUrl!.isNotEmpty)) {
|
||||
debugPrint(
|
||||
"[Auth] External integration detected (popup or redirect). Notifying...",
|
||||
);
|
||||
WebAuthIntegration.sendLoginSuccess(token);
|
||||
return;
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint("[Auth] Login success. Navigating to root.");
|
||||
@@ -1224,7 +1238,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
if (_drySendEnabled) ...[
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3CD),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
@@ -1232,13 +1249,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
|
||||
const Icon(
|
||||
Icons.warning_amber_rounded,
|
||||
color: Color(0xFF8A6D3B),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
tr(
|
||||
'msg.userfront.login.dry_send',
|
||||
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
||||
fallback:
|
||||
'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF8A6D3B),
|
||||
@@ -1294,7 +1315,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
fallback: '이메일 또는 휴대폰 번호',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
prefixIcon: const Icon(
|
||||
Icons.person_outline,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
@@ -1308,7 +1331,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
fallback: '비밀번호',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
prefixIcon: const Icon(
|
||||
Icons.lock_outline,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _handlePasswordLogin(),
|
||||
),
|
||||
@@ -1319,7 +1344,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: Text(
|
||||
tr('ui.userfront.login.action.submit', fallback: '로그인'),
|
||||
tr(
|
||||
'ui.userfront.login.action.submit',
|
||||
fallback: '로그인',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1340,7 +1368,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
),
|
||||
hintText: '',
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: const Icon(Icons.person_outline),
|
||||
prefixIcon: const Icon(
|
||||
Icons.person_outline,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _handleLinkLogin(),
|
||||
),
|
||||
@@ -1363,7 +1393,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
'msg.userfront.login.link.helper',
|
||||
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
|
||||
),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
@@ -1371,9 +1404,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
Text(
|
||||
tr(
|
||||
'msg.userfront.login.link.short_code_help',
|
||||
fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
|
||||
fallback:
|
||||
'링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
|
||||
),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -1382,16 +1419,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: TextField(
|
||||
controller: _shortCodePrefixController,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
controller:
|
||||
_shortCodePrefixController,
|
||||
textCapitalization:
|
||||
TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
'ui.userfront.login.short_code.prefix',
|
||||
fallback: '영문 2자리',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
border:
|
||||
const OutlineInputBorder(),
|
||||
hintText: 'AB',
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
hintStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
maxLength: 2,
|
||||
),
|
||||
@@ -1400,22 +1442,28 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: TextField(
|
||||
controller: _shortCodeDigitsController,
|
||||
controller:
|
||||
_shortCodeDigitsController,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
'ui.userfront.login.short_code.digits',
|
||||
fallback: '숫자 6자리',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
border:
|
||||
const OutlineInputBorder(),
|
||||
hintText: '345678',
|
||||
hintStyle: const TextStyle(color: Colors.grey),
|
||||
hintStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
suffixText: _linkExpireSeconds > 0
|
||||
? tr(
|
||||
'ui.userfront.login.short_code.expire_time',
|
||||
fallback: '유효시간 {{time}}',
|
||||
params: {
|
||||
'time': _formatTime(_linkExpireSeconds),
|
||||
'time': _formatTime(
|
||||
_linkExpireSeconds,
|
||||
),
|
||||
},
|
||||
)
|
||||
: null,
|
||||
@@ -1428,13 +1476,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const SizedBox(height: 12),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
|
||||
final digits = _shortCodeDigitsController.text.trim();
|
||||
if (prefix.length != 2 || digits.length != 6) {
|
||||
final prefix =
|
||||
_shortCodePrefixController.text
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
final digits =
|
||||
_shortCodeDigitsController.text
|
||||
.trim();
|
||||
if (prefix.length != 2 ||
|
||||
digits.length != 6) {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.short_code.invalid',
|
||||
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.',
|
||||
fallback:
|
||||
'문자 2개와 숫자 6자리를 입력해 주세요.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
@@ -1458,27 +1513,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_showInfo(
|
||||
tr(
|
||||
'msg.userfront.login.link.resend_wait',
|
||||
fallback: '재발송은 {{time}} 후 가능합니다.',
|
||||
fallback:
|
||||
'재발송은 {{time}} 후 가능합니다.',
|
||||
params: {
|
||||
'time': _formatTime(_linkResendSeconds),
|
||||
'time': _formatTime(
|
||||
_linkResendSeconds,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
||||
final loginId =
|
||||
_lastLinkLoginId ??
|
||||
_linkIdController.text.trim();
|
||||
if (loginId.isEmpty) {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.login.link.missing_login_id',
|
||||
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.',
|
||||
fallback:
|
||||
'이메일 또는 휴대폰 번호를 입력해 주세요.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
_startEnchantedFlow(
|
||||
loginId,
|
||||
isEmail: _lastLinkIsEmail || loginId.contains('@'),
|
||||
isEmail:
|
||||
_lastLinkIsEmail ||
|
||||
loginId.contains('@'),
|
||||
codeOnly: false,
|
||||
);
|
||||
},
|
||||
@@ -1488,7 +1551,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
'ui.userfront.login.link.resend_with_time',
|
||||
fallback: '재발송 ({{time}})',
|
||||
params: {
|
||||
'time': _formatTime(_linkResendSeconds),
|
||||
'time': _formatTime(
|
||||
_linkResendSeconds,
|
||||
),
|
||||
},
|
||||
)
|
||||
: tr(
|
||||
@@ -1505,15 +1570,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
_showInfo(
|
||||
tr(
|
||||
'msg.userfront.login.link.resend_wait',
|
||||
fallback: '재발송은 {{time}} 후 가능합니다.',
|
||||
fallback:
|
||||
'재발송은 {{time}} 후 가능합니다.',
|
||||
params: {
|
||||
'time': _formatTime(_linkResendSeconds),
|
||||
'time': _formatTime(
|
||||
_linkResendSeconds,
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
|
||||
final loginId =
|
||||
_lastLinkLoginId ??
|
||||
_linkIdController.text.trim();
|
||||
if (loginId.isEmpty) {
|
||||
_showError(
|
||||
tr(
|
||||
@@ -1534,7 +1604,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
'ui.userfront.login.link.code_only',
|
||||
fallback: '코드만 받기({{time}})',
|
||||
params: {
|
||||
'time': _formatTime(_linkResendSeconds),
|
||||
'time': _formatTime(
|
||||
_linkResendSeconds,
|
||||
),
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -1553,13 +1625,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const CircularProgressIndicator()
|
||||
else if (_qrImageBase64 != null)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.shade300),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade300,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(
|
||||
12,
|
||||
),
|
||||
),
|
||||
child: QrImageView(
|
||||
data: _qrImageBase64!,
|
||||
@@ -1570,20 +1647,24 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_qrRemainingSeconds > 0
|
||||
? tr(
|
||||
'ui.userfront.login.qr.remaining',
|
||||
fallback: '남은 시간: {{time}}',
|
||||
params: {
|
||||
'time': _formatTime(_qrRemainingSeconds),
|
||||
},
|
||||
)
|
||||
: tr(
|
||||
'ui.userfront.login.qr.expired',
|
||||
fallback: 'QR 코드 만료됨',
|
||||
),
|
||||
? tr(
|
||||
'ui.userfront.login.qr.remaining',
|
||||
fallback: '남은 시간: {{time}}',
|
||||
params: {
|
||||
'time': _formatTime(
|
||||
_qrRemainingSeconds,
|
||||
),
|
||||
},
|
||||
)
|
||||
: tr(
|
||||
'ui.userfront.login.qr.expired',
|
||||
fallback: 'QR 코드 만료됨',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: _qrRemainingSeconds > 30 ? Colors.blue : Colors.red,
|
||||
color: _qrRemainingSeconds > 30
|
||||
? Colors.blue
|
||||
: Colors.red,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
@@ -1594,7 +1675,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
fallback: '모바일 앱으로 스캔하세요',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 12),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _startQrFlow,
|
||||
@@ -1640,7 +1724,10 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
|
||||
'msg.userfront.login.no_account',
|
||||
fallback: '계정이 없으신가요?',
|
||||
),
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 14),
|
||||
style: const TextStyle(
|
||||
color: Colors.grey,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.push('/signup'),
|
||||
|
||||
@@ -14,23 +14,27 @@ class LoginSuccessScreen extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.check_circle_outline, size: 80, color: Colors.green),
|
||||
const Icon(
|
||||
Icons.check_circle_outline,
|
||||
size: 80,
|
||||
color: Colors.green,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
tr('ui.userfront.login_success.title', fallback: '로그인 완료'),
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'),
|
||||
tr(
|
||||
'msg.userfront.login_success.subtitle',
|
||||
fallback: '성공적으로 로그인되었습니다.',
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 16),
|
||||
),
|
||||
const SizedBox(height: 48),
|
||||
|
||||
|
||||
// 이 버튼이 QR 카메라를 켜는 버튼입니다.
|
||||
FilledButton.icon(
|
||||
onPressed: () {
|
||||
@@ -38,12 +42,18 @@ class LoginSuccessScreen extends StatelessWidget {
|
||||
},
|
||||
icon: const Icon(Icons.camera_alt, size: 28),
|
||||
label: Text(
|
||||
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'),
|
||||
tr(
|
||||
'ui.userfront.login_success.qr',
|
||||
fallback: 'QR 인증 (카메라 켜기)',
|
||||
),
|
||||
),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
|
||||
backgroundColor: Colors.blue.shade700,
|
||||
textStyle: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
|
||||
@@ -87,7 +87,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
|
||||
Future<void> _onDetect(BarcodeCapture capture) async {
|
||||
if (_isScanned) return;
|
||||
|
||||
|
||||
final List<Barcode> barcodes = capture.barcodes;
|
||||
for (final barcode in barcodes) {
|
||||
if (barcode.rawValue != null) {
|
||||
@@ -119,7 +119,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
|
||||
_log.info('QR Code detected raw: $qrData, ref: $pendingRef');
|
||||
final approveRef = qrData;
|
||||
|
||||
|
||||
final storedToken = AuthTokenStore.getToken();
|
||||
final sessionToken = storedToken;
|
||||
var usesCookie = AuthTokenStore.usesCookie();
|
||||
@@ -140,7 +140,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
token: sessionToken,
|
||||
withCredentials: usesCookie,
|
||||
);
|
||||
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isSuccess = true;
|
||||
@@ -226,7 +226,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color),
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
@@ -268,7 +272,8 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
controller: controller,
|
||||
onDetect: _onDetect,
|
||||
errorBuilder: (context, error) {
|
||||
final isPermissionDenied = error.errorCode ==
|
||||
final isPermissionDenied =
|
||||
error.errorCode ==
|
||||
MobileScannerErrorCode.permissionDenied;
|
||||
return Center(
|
||||
child: Column(
|
||||
@@ -295,7 +300,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
|
||||
: _requestCameraPermission,
|
||||
child: Text(
|
||||
_isRequestingCamera
|
||||
? tr('ui.common.requesting', fallback: '요청 중...')
|
||||
? tr(
|
||||
'ui.common.requesting',
|
||||
fallback: '요청 중...',
|
||||
)
|
||||
: tr(
|
||||
'ui.userfront.qr.request_permission',
|
||||
fallback: '카메라 권한 요청하기',
|
||||
|
||||
@@ -13,7 +13,8 @@ class ResetPasswordScreen extends StatefulWidget {
|
||||
|
||||
class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController = TextEditingController();
|
||||
final TextEditingController _confirmPasswordController =
|
||||
TextEditingController();
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
bool _isLoading = false;
|
||||
String? _loginId;
|
||||
@@ -31,13 +32,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
|
||||
// 2. Fallback to URI query parameter if not available via router
|
||||
if (_loginId == null || _loginId!.isEmpty) {
|
||||
final uri = Uri.base;
|
||||
_loginId = uri.queryParameters['loginId'];
|
||||
}
|
||||
final uri = Uri.base;
|
||||
_loginId = uri.queryParameters['loginId'];
|
||||
}
|
||||
|
||||
// 토큰도 함께 읽어놓는다.
|
||||
final uri = Uri.base;
|
||||
_token = uri.queryParameters['token'];
|
||||
// 토큰도 함께 읽어놓는다.
|
||||
final uri = Uri.base;
|
||||
_token = uri.queryParameters['token'];
|
||||
|
||||
_loadPolicy();
|
||||
}
|
||||
@@ -66,7 +67,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
|
||||
Future<void> _handlePasswordReset() async {
|
||||
if (_formKey.currentState?.validate() != true) return;
|
||||
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
|
||||
if ((_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)) {
|
||||
_showError(
|
||||
tr(
|
||||
'msg.userfront.reset.invalid_link',
|
||||
@@ -163,9 +165,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
);
|
||||
}
|
||||
if (requiresNumber) {
|
||||
parts.add(
|
||||
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
|
||||
);
|
||||
parts.add(tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'));
|
||||
}
|
||||
if (requiresSymbol) {
|
||||
parts.add(
|
||||
@@ -180,16 +180,16 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
|
||||
),
|
||||
title: Text(tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정')),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 400),
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: (_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)
|
||||
child:
|
||||
(_loginId == null || _loginId!.isEmpty) &&
|
||||
(_token == null || _token!.isEmpty)
|
||||
? _buildInvalidTokenView()
|
||||
: Form(
|
||||
key: _formKey,
|
||||
@@ -227,7 +227,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
||||
_isPasswordObscured
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
@@ -244,7 +246,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
fallback: '비밀번호를 입력해주세요.',
|
||||
);
|
||||
}
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minLength =
|
||||
(_policy?['minLength'] as int?) ?? 12;
|
||||
if (val.length < minLength) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.min_length',
|
||||
@@ -262,7 +265,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
if (hasNumber) typeCount++;
|
||||
if (hasSymbol) typeCount++;
|
||||
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
final minTypes =
|
||||
(_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
if (minTypes > 0 && typeCount < minTypes) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.min_types',
|
||||
@@ -290,7 +294,8 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
|
||||
);
|
||||
}
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
|
||||
if ((_policy?['nonAlphanumeric'] ?? true) &&
|
||||
!hasSymbol) {
|
||||
return tr(
|
||||
'msg.userfront.reset.error.symbol',
|
||||
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
|
||||
@@ -312,11 +317,14 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
prefixIcon: const Icon(Icons.lock_outline),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_isConfirmPasswordObscured ? Icons.visibility_off : Icons.visibility,
|
||||
_isConfirmPasswordObscured
|
||||
? Icons.visibility_off
|
||||
: Icons.visibility,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_isConfirmPasswordObscured = !_isConfirmPasswordObscured;
|
||||
_isConfirmPasswordObscured =
|
||||
!_isConfirmPasswordObscured;
|
||||
});
|
||||
},
|
||||
),
|
||||
@@ -369,8 +377,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
|
||||
const Icon(Icons.error_outline, color: Colors.red, size: 60),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
tr('msg.userfront.reset.invalid_title',
|
||||
fallback: '유효하지 않은 링크입니다.'),
|
||||
tr('msg.userfront.reset.invalid_title', fallback: '유효하지 않은 링크입니다.'),
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
|
||||
@@ -164,30 +164,39 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
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',
|
||||
fallback: '유효한 이메일 형식이 아닙니다.',
|
||||
));
|
||||
setState(
|
||||
() => _emailError = tr(
|
||||
'msg.userfront.signup.email.invalid',
|
||||
fallback: '유효한 이메일 형식이 아닙니다.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setState(() { _isLoading = true; _emailError = null; });
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_emailError = null;
|
||||
});
|
||||
try {
|
||||
final available = await AuthProxyService.checkEmailAvailability(email);
|
||||
if (!available) {
|
||||
setState(() => _emailError = tr(
|
||||
'msg.userfront.signup.email.duplicate',
|
||||
fallback: '이미 가입된 이메일입니다.',
|
||||
));
|
||||
setState(
|
||||
() => _emailError = tr(
|
||||
'msg.userfront.signup.email.duplicate',
|
||||
fallback: '이미 가입된 이메일입니다.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await AuthProxyService.sendSignupCode(email, 'email');
|
||||
_startTimer('email');
|
||||
} catch (e) {
|
||||
setState(() => _emailError = tr(
|
||||
'msg.userfront.signup.email.send_failed',
|
||||
fallback: '발송 실패: {{error}}',
|
||||
params: {'error': e.toString()},
|
||||
));
|
||||
setState(
|
||||
() => _emailError = tr(
|
||||
'msg.userfront.signup.email.send_failed',
|
||||
fallback: '발송 실패: {{error}}',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
@@ -197,7 +206,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
final code = _emailCodeController.text.trim();
|
||||
if (code.length != 6) return;
|
||||
try {
|
||||
final success = await AuthProxyService.verifySignupCode(_emailController.text.trim(), 'email', code);
|
||||
final success = await AuthProxyService.verifySignupCode(
|
||||
_emailController.text.trim(),
|
||||
'email',
|
||||
code,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isEmailVerified = true;
|
||||
@@ -206,33 +219,42 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
_emailError = null;
|
||||
});
|
||||
} else {
|
||||
setState(() => _emailError = tr(
|
||||
'msg.userfront.signup.email.code_mismatch',
|
||||
fallback: '인증코드가 일치하지 않습니다.',
|
||||
));
|
||||
setState(
|
||||
() => _emailError = tr(
|
||||
'msg.userfront.signup.email.code_mismatch',
|
||||
fallback: '인증코드가 일치하지 않습니다.',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _emailError = tr(
|
||||
'msg.userfront.signup.email.verify_failed',
|
||||
fallback: '인증 실패: {{error}}',
|
||||
params: {'error': e.toString()},
|
||||
));
|
||||
setState(
|
||||
() => _emailError = tr(
|
||||
'msg.userfront.signup.email.verify_failed',
|
||||
fallback: '인증 실패: {{error}}',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _sendPhoneCode() async {
|
||||
final phone = _phoneController.text.trim();
|
||||
if (phone.isEmpty) return;
|
||||
setState(() { _isLoading = true; _phoneError = null; });
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_phoneError = null;
|
||||
});
|
||||
try {
|
||||
await AuthProxyService.sendSignupCode(phone, 'phone');
|
||||
_startTimer('phone');
|
||||
} catch (e) {
|
||||
setState(() => _phoneError = tr(
|
||||
'msg.userfront.signup.phone.send_failed',
|
||||
fallback: '발송 실패: {{error}}',
|
||||
params: {'error': e.toString()},
|
||||
));
|
||||
setState(
|
||||
() => _phoneError = tr(
|
||||
'msg.userfront.signup.phone.send_failed',
|
||||
fallback: '발송 실패: {{error}}',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
setState(() => _isLoading = false);
|
||||
}
|
||||
@@ -242,7 +264,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
final code = _phoneCodeController.text.trim();
|
||||
if (code.length != 6) return;
|
||||
try {
|
||||
final success = await AuthProxyService.verifySignupCode(_phoneController.text.trim(), 'phone', code);
|
||||
final success = await AuthProxyService.verifySignupCode(
|
||||
_phoneController.text.trim(),
|
||||
'phone',
|
||||
code,
|
||||
);
|
||||
if (success) {
|
||||
setState(() {
|
||||
_isPhoneVerified = true;
|
||||
@@ -251,26 +277,32 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
_phoneError = null;
|
||||
});
|
||||
} else {
|
||||
setState(() => _phoneError = tr(
|
||||
'msg.userfront.signup.phone.code_mismatch',
|
||||
fallback: '인증코드가 일치하지 않습니다.',
|
||||
));
|
||||
setState(
|
||||
() => _phoneError = tr(
|
||||
'msg.userfront.signup.phone.code_mismatch',
|
||||
fallback: '인증코드가 일치하지 않습니다.',
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _phoneError = tr(
|
||||
'msg.userfront.signup.phone.verify_failed',
|
||||
fallback: '인증 실패: {{error}}',
|
||||
params: {'error': e.toString()},
|
||||
));
|
||||
setState(
|
||||
() => _phoneError = tr(
|
||||
'msg.userfront.signup.phone.verify_failed',
|
||||
fallback: '인증 실패: {{error}}',
|
||||
params: {'error': e.toString()},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _handleSignup() async {
|
||||
if (_passwordController.text != _confirmPasswordController.text) {
|
||||
setState(() => _confirmPasswordError = tr(
|
||||
'msg.userfront.signup.password.mismatch',
|
||||
fallback: '비밀번호가 일치하지 않습니다.',
|
||||
));
|
||||
setState(
|
||||
() => _confirmPasswordError = tr(
|
||||
'msg.userfront.signup.password.mismatch',
|
||||
fallback: '비밀번호가 일치하지 않습니다.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!_formKey.currentState!.validate()) return;
|
||||
@@ -288,7 +320,9 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
phone: _phoneController.text.trim(),
|
||||
affiliationType: _affiliationType,
|
||||
companyCode: _affiliationType == 'AFFILIATE' ? _companyCode : null,
|
||||
department: _deptController.text.trim().isEmpty ? (_affiliationType == 'GENERAL' ? 'External' : '') : _deptController.text.trim(),
|
||||
department: _deptController.text.trim().isEmpty
|
||||
? (_affiliationType == 'GENERAL' ? 'External' : '')
|
||||
: _deptController.text.trim(),
|
||||
termsAccepted: true,
|
||||
);
|
||||
if (mounted) _showSuccessDialog();
|
||||
@@ -394,11 +428,28 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
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)),
|
||||
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)),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: isCurrent ? Colors.black : Colors.grey,
|
||||
fontWeight: isCurrent ? FontWeight.bold : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -438,10 +489,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
),
|
||||
child: CheckboxListTile(
|
||||
title: Text(
|
||||
tr(
|
||||
'ui.userfront.signup.agreement.all',
|
||||
fallback: '모두 동의합니다',
|
||||
),
|
||||
tr('ui.userfront.signup.agreement.all', fallback: '모두 동의합니다'),
|
||||
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
|
||||
),
|
||||
value: _termsAccepted && _privacyAccepted,
|
||||
@@ -488,8 +536,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
return Column(
|
||||
children: [
|
||||
CheckboxListTile(
|
||||
title: Text(title,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
title: Text(
|
||||
title,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
@@ -508,7 +558,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
child: SingleChildScrollView(
|
||||
child: Text(
|
||||
content,
|
||||
style: const TextStyle(fontSize: 12, color: Colors.grey, height: 1.5),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -517,8 +571,8 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
}
|
||||
|
||||
static String get _tosText => tr(
|
||||
'msg.userfront.signup.tos_full',
|
||||
fallback: """
|
||||
'msg.userfront.signup.tos_full',
|
||||
fallback: """
|
||||
바론 소프트웨어 이용약관
|
||||
|
||||
제1장 총칙
|
||||
@@ -589,11 +643,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
부칙
|
||||
본 약관은 2024년 10월 1일부터 시행됩니다.
|
||||
""",
|
||||
);
|
||||
);
|
||||
|
||||
static String get _privacyText => tr(
|
||||
'msg.userfront.signup.privacy_full',
|
||||
fallback: """
|
||||
'msg.userfront.signup.privacy_full',
|
||||
fallback: """
|
||||
개인정보 수집 및 이용 동의
|
||||
|
||||
바론서비스 개인정보처리방침
|
||||
@@ -702,7 +756,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
제8조 (기타)
|
||||
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
|
||||
""",
|
||||
);
|
||||
);
|
||||
|
||||
Widget _buildStepAuth() {
|
||||
return Column(
|
||||
@@ -719,7 +773,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
// 가족사 이메일 안내 문구
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
|
||||
@@ -730,7 +787,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
'msg.userfront.signup.auth.affiliate_notice',
|
||||
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
|
||||
),
|
||||
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -753,7 +814,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
'ui.userfront.signup.auth.email.label',
|
||||
fallback: '이메일 주소',
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _emailError,
|
||||
hintText: 'example@hanmaceng.co.kr',
|
||||
),
|
||||
@@ -764,8 +825,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
SizedBox(
|
||||
height: 55,
|
||||
child: ElevatedButton(
|
||||
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
||||
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', fallback: '재발송')
|
||||
@@ -791,8 +858,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
||||
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val.length == 6) _verifyEmailCode();
|
||||
},
|
||||
),
|
||||
],
|
||||
if (_isEmailVerified)
|
||||
@@ -837,8 +909,14 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
SizedBox(
|
||||
height: 55,
|
||||
child: ElevatedButton(
|
||||
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
|
||||
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', fallback: '재발송')
|
||||
@@ -864,8 +942,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6)],
|
||||
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
|
||||
inputFormatters: [
|
||||
FilteringTextInputFormatter.digitsOnly,
|
||||
LengthLimitingTextInputFormatter(6),
|
||||
],
|
||||
onChanged: (val) {
|
||||
if (val.length == 6) _verifyPhoneCode();
|
||||
},
|
||||
),
|
||||
],
|
||||
if (_isPhoneVerified)
|
||||
@@ -903,10 +986,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
controller: _nameController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
labelText: tr(
|
||||
'ui.userfront.signup.profile.name',
|
||||
fallback: '이름',
|
||||
),
|
||||
labelText: tr('ui.userfront.signup.profile.name', fallback: '이름'),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
@@ -936,19 +1016,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
DropdownMenuItem(
|
||||
value: 'GENERAL',
|
||||
child: Text(
|
||||
tr(
|
||||
'domain.affiliation.general',
|
||||
fallback: '일반 사용자',
|
||||
),
|
||||
tr('domain.affiliation.general', fallback: '일반 사용자'),
|
||||
),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'AFFILIATE',
|
||||
child: Text(
|
||||
tr(
|
||||
'domain.affiliation.affiliate',
|
||||
fallback: '가족사 임직원',
|
||||
),
|
||||
tr('domain.affiliation.affiliate', fallback: '가족사 임직원'),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -985,39 +1059,27 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
items: [
|
||||
DropdownMenuItem(
|
||||
value: 'HANMAC',
|
||||
child: Text(
|
||||
tr('domain.company.hanmac', fallback: '한맥'),
|
||||
),
|
||||
child: Text(tr('domain.company.hanmac', fallback: '한맥')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'SAMAN',
|
||||
child: Text(
|
||||
tr('domain.company.saman', fallback: '삼안'),
|
||||
),
|
||||
child: Text(tr('domain.company.saman', fallback: '삼안')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'PTC',
|
||||
child: Text(
|
||||
tr('domain.company.ptc', fallback: 'PTC'),
|
||||
),
|
||||
child: Text(tr('domain.company.ptc', fallback: 'PTC')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'JANGHEON',
|
||||
child: Text(
|
||||
tr('domain.company.jangheon', fallback: '장헌'),
|
||||
),
|
||||
child: Text(tr('domain.company.jangheon', fallback: '장헌')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'BARON',
|
||||
child: Text(
|
||||
tr('domain.company.baron', fallback: '바론'),
|
||||
),
|
||||
child: Text(tr('domain.company.baron', fallback: '바론')),
|
||||
),
|
||||
DropdownMenuItem(
|
||||
value: 'HALLA',
|
||||
child: Text(
|
||||
tr('domain.company.halla', fallback: '한라'),
|
||||
),
|
||||
child: Text(tr('domain.company.halla', fallback: '한라')),
|
||||
),
|
||||
],
|
||||
onChanged: _isAffiliateEmail
|
||||
@@ -1038,7 +1100,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
'ui.userfront.signup.profile.department_optional',
|
||||
fallback: '소속 정보 (선택)',
|
||||
),
|
||||
border: const OutlineInputBorder()
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1076,36 +1138,16 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
);
|
||||
}
|
||||
if (requiresUpper) {
|
||||
parts.add(
|
||||
tr(
|
||||
'msg.userfront.signup.policy.uppercase',
|
||||
fallback: '대문자',
|
||||
),
|
||||
);
|
||||
parts.add(tr('msg.userfront.signup.policy.uppercase', fallback: '대문자'));
|
||||
}
|
||||
if (requiresLower) {
|
||||
parts.add(
|
||||
tr(
|
||||
'msg.userfront.signup.policy.lowercase',
|
||||
fallback: '소문자',
|
||||
),
|
||||
);
|
||||
parts.add(tr('msg.userfront.signup.policy.lowercase', fallback: '소문자'));
|
||||
}
|
||||
if (requiresNumber) {
|
||||
parts.add(
|
||||
tr(
|
||||
'msg.userfront.signup.policy.number',
|
||||
fallback: '숫자',
|
||||
),
|
||||
);
|
||||
parts.add(tr('msg.userfront.signup.policy.number', fallback: '숫자'));
|
||||
}
|
||||
if (requiresSymbol) {
|
||||
parts.add(
|
||||
tr(
|
||||
'msg.userfront.signup.policy.symbol',
|
||||
fallback: '특수문자',
|
||||
),
|
||||
);
|
||||
parts.add(tr('msg.userfront.signup.policy.symbol', fallback: '특수문자'));
|
||||
}
|
||||
|
||||
return tr(
|
||||
@@ -1117,7 +1159,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
|
||||
Widget _buildStepPassword() {
|
||||
String p = _passwordController.text;
|
||||
|
||||
|
||||
// Default Policy Fallback
|
||||
final minLength = (_policy?['minLength'] as int?) ?? 12;
|
||||
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
|
||||
@@ -1152,7 +1194,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
// 비밀번호 정책 안내 박스
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(8)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[50],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.security, size: 18, color: Colors.blue),
|
||||
@@ -1160,7 +1205,11 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
Expanded(
|
||||
child: Text(
|
||||
_buildPolicyDescription(),
|
||||
style: TextStyle(fontSize: 12, color: Colors.blue[800], fontWeight: FontWeight.w500),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue[800],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -1219,10 +1268,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
),
|
||||
if (requiresNumber)
|
||||
_cryptoCheck(
|
||||
tr(
|
||||
'msg.userfront.signup.password.rule.number',
|
||||
fallback: '숫자',
|
||||
),
|
||||
tr('msg.userfront.signup.password.rule.number', fallback: '숫자'),
|
||||
hasDigit,
|
||||
),
|
||||
if (requiresSymbol)
|
||||
@@ -1253,7 +1299,7 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
labelText: tr(
|
||||
'ui.userfront.signup.password.confirm_label',
|
||||
fallback: '비밀번호 확인',
|
||||
),
|
||||
),
|
||||
border: const OutlineInputBorder(),
|
||||
errorText: _confirmPasswordError,
|
||||
),
|
||||
@@ -1266,9 +1312,19 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(isValid ? Icons.check_circle : Icons.circle_outlined, size: 14, color: isValid ? Colors.green : Colors.grey),
|
||||
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)),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: isValid ? Colors.green : Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -1276,8 +1332,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool canGoNext = false;
|
||||
if (_currentStep == 1 && _termsAccepted && _privacyAccepted) canGoNext = true;
|
||||
if (_currentStep == 2 && _isEmailVerified && _isPhoneVerified) canGoNext = true;
|
||||
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') {
|
||||
@@ -1313,11 +1371,13 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: _currentStep == 1
|
||||
? _buildStepAgreement()
|
||||
: (_currentStep == 2
|
||||
? _buildStepAuth()
|
||||
: (_currentStep == 3 ? _buildStepInfo() : _buildStepPassword())),
|
||||
child: _currentStep == 1
|
||||
? _buildStepAgreement()
|
||||
: (_currentStep == 2
|
||||
? _buildStepAuth()
|
||||
: (_currentStep == 3
|
||||
? _buildStepInfo()
|
||||
: _buildStepPassword())),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -1329,7 +1389,10 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => setState(() => _currentStep--),
|
||||
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(55),
|
||||
side: const BorderSide(color: Colors.black),
|
||||
),
|
||||
child: Text(
|
||||
tr('ui.common.prev', fallback: '이전'),
|
||||
style: const TextStyle(color: Colors.black),
|
||||
@@ -1340,20 +1403,35 @@ class _SignupScreenState extends State<SignupScreen> {
|
||||
],
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _currentStep < 4
|
||||
? (canGoNext ? () => setState(() => _currentStep++) : null)
|
||||
: (_isLoading ? null : _handleSignup),
|
||||
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', fallback: '다음 단계')
|
||||
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
height: 20,
|
||||
width: 20,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
_currentStep < 4
|
||||
? tr(
|
||||
'ui.userfront.signup.next_step',
|
||||
fallback: '다음 단계',
|
||||
)
|
||||
: tr(
|
||||
'ui.userfront.signup.complete',
|
||||
fallback: '가입 완료',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user