1
0
forked from baron/baron-sso

i18n 대대적 변경

This commit is contained in:
Lectom C Han
2026-02-10 19:13:00 +09:00
parent 798d37bed9
commit 8df95c8a13
27 changed files with 3910 additions and 594 deletions

View File

@@ -13,12 +13,6 @@ class ConsentScreen extends StatefulWidget {
}
class _ConsentScreenState extends State<ConsentScreen> {
static const _ink = Color(0xFF1A1F2C);
static const _surface = Colors.white;
static const _border = Color(0xFFE5E7EB);
static const _subtle = Color(0xFFF7F8FA);
static const _accent = Color(0xFF2563EB);
Map<String, dynamic>? _consentInfo;
bool _isLoading = true;
bool _isSubmitting = false;
@@ -28,7 +22,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
final Set<String> _selectedScopes = {};
// 권한별 설명 매핑 (동적으로 업데이트됨)
Map<String, String> _scopeDescriptions = {
final Map<String, String> _scopeDescriptions = {
'openid': 'OpenID 인증 정보 (로그인 상태 확인)',
'profile': '기본 프로필 정보 (이름, 사용자 식별자)',
'email': '이메일 주소 (계정 식별 및 알림 용도)',
@@ -37,7 +31,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
};
// 필수 권한 목록 (동적으로 업데이트됨)
Set<String> _mandatoryScopes = {'openid'};
final Set<String> _mandatoryScopes = {'openid'};
@override
void initState() {
@@ -333,7 +327,7 @@ class _ConsentScreenState extends State<ConsentScreen> {
contentPadding: EdgeInsets.zero,
activeColor: Theme.of(context).primaryColor,
);
}).toList(),
}),
const Divider(),
const SizedBox(height: 32),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/constants/error_whitelist.dart';
import '../../../core/services/auth_proxy_service.dart';
import 'package:userfront/i18n.dart';
class ErrorScreen extends StatelessWidget {
final String? errorId;
@@ -23,19 +24,38 @@ class ErrorScreen extends StatelessWidget {
final isProd = isProdOverride ?? AuthProxyService.isProdEnv;
final normalizedCode = (errorCode ?? '').trim();
final hasCode = normalizedCode.isNotEmpty;
final whitelistMessage = errorWhitelistMessages[normalizedCode];
final isWhitelisted = whitelistMessage != null;
final whitelistFallback = errorWhitelistMessages[normalizedCode];
final isWhitelisted = whitelistFallback != null;
final errorType = isProd
? (isWhitelisted && hasCode ? normalizedCode : 'unknown_error')
: (hasCode ? normalizedCode : 'unknown_error');
final title = isProd
? '인증 과정에서 오류가 발생했습니다'
: (hasCode ? '오류: $normalizedCode' : '오류가 발생했습니다');
? 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: '오류가 발생했습니다'));
final detail = isProd
? (isWhitelisted ? whitelistMessage! : '에러가 계속되면 관리자에게 문의해주세요')
? (isWhitelisted
? tr(
'msg.userfront.error.whitelist.$normalizedCode',
fallback: whitelistFallback,
)
: tr(
'msg.userfront.error.detail_contact',
fallback: '에러가 계속되면 관리자에게 문의해주세요',
))
: ((description?.isNotEmpty == true)
? description!
: (hasCode ? '오류가 발생했습니다.' : '요청을 처리하는 중 문제가 발생했습니다.'));
: (hasCode
? tr('msg.userfront.error.detail_generic', fallback: '오류가 발생했습니다.')
: tr(
'msg.userfront.error.detail_request',
fallback: '요청을 처리하는 중 문제가 발생했습니다.',
)));
return Scaffold(
backgroundColor: const Color(0xFFF7F8FA),
@@ -72,7 +92,11 @@ class ErrorScreen extends StatelessWidget {
),
const SizedBox(height: 12),
Text(
'오류 종류: $errorType',
tr(
'msg.userfront.error.type',
fallback: '오류 종류: {{type}}',
params: {'type': errorType},
),
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280),
),
@@ -80,7 +104,11 @@ class ErrorScreen extends StatelessWidget {
if (errorId != null && errorId!.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
'오류 ID: $errorId',
tr(
'msg.userfront.error.id',
fallback: '오류 ID: {{id}}',
params: {'id': errorId!},
),
style: theme.textTheme.bodySmall?.copyWith(
color: const Color(0xFF6B7280),
),
@@ -101,7 +129,9 @@ class ErrorScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('로그인으로 이동'),
child: Text(
tr('ui.userfront.error.go_login', fallback: '로그인으로 이동'),
),
),
OutlinedButton(
onPressed: () => context.go('/'),
@@ -113,7 +143,9 @@ class ErrorScreen extends StatelessWidget {
borderRadius: BorderRadius.circular(10),
),
),
child: const Text('홈으로 이동'),
child: Text(
tr('ui.userfront.error.go_home', fallback: '홈으로 이동'),
),
),
],
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../../core/services/auth_proxy_service.dart';
import 'package:userfront/i18n.dart';
class ForgotPasswordScreen extends StatefulWidget {
const ForgotPasswordScreen({super.key});
@@ -22,7 +23,12 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
Future<void> _handlePasswordReset() async {
final input = _loginIdController.text.trim();
if (input.isEmpty) {
_showError("이메일 또는 휴대폰 번호를 입력해주세요.");
_showError(
tr(
'msg.userfront.forgot.input_required',
fallback: '이메일 또는 휴대폰 번호를 입력해주세요.',
),
);
return;
}
@@ -41,8 +47,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
await AuthProxyService.initiatePasswordReset(loginId, drySend: _drySendEnabled);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요."),
SnackBar(
content: Text(
tr(
'msg.userfront.forgot.sent',
fallback: '비밀번호 재설정 링크가 전송되었습니다. 이메일 또는 SMS를 확인해주세요.',
),
),
backgroundColor: Colors.green,
),
);
@@ -50,7 +61,13 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
}
} catch (e) {
if (mounted) {
_showError("전송에 실패했습니다: ${e.toString()}");
_showError(
tr(
'msg.userfront.forgot.error',
fallback: '전송에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
}
} finally {
if (mounted) {
@@ -77,7 +94,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("비밀번호 재설정"),
title: Text(tr('ui.userfront.forgot.title', fallback: '비밀번호 재설정')),
centerTitle: true,
),
body: Center(
@@ -89,7 +106,7 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"비밀번호를 잊으셨나요?",
tr('ui.userfront.forgot.heading', fallback: '비밀번호를 잊으셨나요?'),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
@@ -106,13 +123,16 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
border: Border.all(color: const Color(0xFFFFC107)),
),
child: Row(
children: const [
Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
SizedBox(width: 8),
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
const SizedBox(width: 8),
Expanded(
child: Text(
"drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.",
style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
tr(
'msg.userfront.forgot.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
),
),
],
@@ -120,18 +140,25 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
),
],
const SizedBox(height: 16),
const Text(
"계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.",
Text(
tr(
'msg.userfront.forgot.description',
fallback:
'계정과 연결된 이메일 주소 또는 휴대폰 번호를 입력하시면, 비밀번호를 재설정할 수 있는 링크를 보내드립니다.',
),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
style: const TextStyle(color: Colors.grey),
),
const SizedBox(height: 40),
TextField(
controller: _loginIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.forgot.input_label',
fallback: '이메일 또는 휴대폰 번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
),
onSubmitted: (_) => _handlePasswordReset(),
),
@@ -147,7 +174,12 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: const Text("재설정 링크 전송"),
: Text(
tr(
'ui.userfront.forgot.submit',
fallback: '재설정 링크 전송',
),
),
),
],
),

View File

@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:qr_flutter/qr_flutter.dart';
import 'package:userfront/i18n.dart';
import '../../../core/services/web_auth_integration.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart';
@@ -51,10 +52,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
bool _verificationOnly = false;
bool _verificationApproved = false;
String _verificationMessage = '';
String _verificationTitle = '승인 완료';
String _verificationPageTitle = '로그인 승인';
String _verificationActionLabel = '확인';
String _verificationActionPath = '/';
String _verificationTitle = tr(
'ui.userfront.login.verification.title',
fallback: '승인 완료',
);
String _verificationPageTitle = tr(
'ui.userfront.login.verification.page_title',
fallback: '로그인 승인',
);
String _verificationActionLabel = tr(
'ui.userfront.login.verification.action_label',
fallback: '확인',
);
Timer? _verificationRedirectTimer;
bool _noticeHandled = false;
bool _drySendEnabled = false;
@@ -92,7 +101,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (!_noticeHandled && notice == 'qr_login_required') {
_noticeHandled = true;
_showInfo('로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다');
_showInfo(
tr(
'msg.userfront.login.qr_login_required',
fallback: '로그인 한 상태여야 QR 스캔으로 로그인 할 수 있습니다',
),
);
}
if (!_verificationOnly) {
@@ -125,7 +139,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
}
} catch (e) {
if (!silent) {
_showError("로그인 확인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
_showError(
tr(
'msg.userfront.login.cookie_check_failed',
fallback: '로그인 확인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
),
);
}
}
}
@@ -294,7 +316,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_startCountdown();
}
} catch (e) {
_showError("Failed to init QR: $e");
_showError(
tr(
'msg.userfront.login.qr_init_failed',
fallback: 'QR 초기화에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
if (mounted) setState(() => _isQrLoading = false);
}
}
@@ -346,7 +374,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (res['error'] == 'expired_token') {
timer.cancel();
_qrCountdownTimer?.cancel();
_showError("QR 세션이 만료되었습니다.");
_showError(
tr(
'msg.userfront.login.qr_expired',
fallback: 'QR 세션이 만료되었습니다.',
),
);
return;
}
@@ -357,7 +390,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (token is String && token.isNotEmpty) {
_completeLoginFromToken(token);
} else {
_showError("로그인 토큰을 확인할 수 없습니다.");
_showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
);
}
}
} catch (e) {
@@ -423,21 +461,35 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
void _markVerificationApproved(
String message, {
String title = '승인 완료',
String pageTitle = '로그인 승인',
String actionLabel = '확인',
String? title,
String? pageTitle,
String? actionLabel,
String actionPath = '/',
bool autoRedirect = false,
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: '확인',
);
setState(() {
_verificationApproved = true;
_verificationMessage = message;
_verificationTitle = title;
_verificationPageTitle = pageTitle;
_verificationActionLabel = actionLabel;
_verificationActionPath = actionPath;
_verificationTitle = resolvedTitle;
_verificationPageTitle = resolvedPageTitle;
_verificationActionLabel = resolvedActionLabel;
});
_verificationRedirectTimer?.cancel();
if (autoRedirect) {
@@ -463,7 +515,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
const SizedBox(height: 12),
Text(
_verificationMessage.isEmpty ? '로그인 승인에 성공했습니다.' : _verificationMessage,
_verificationMessage.isEmpty
? tr(
'msg.userfront.login.verification.success',
fallback: '로그인 승인에 성공했습니다.',
)
: _verificationMessage,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.black54),
),
@@ -490,6 +547,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Future<void> _verifyToken(String token) async {
debugPrint("[Auth] Starting verification for token: $token");
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다',
);
try {
// Use Backend to verify the token (Backend-Driven Flow)
final res = await AuthProxyService.verifyMagicLink(
@@ -505,7 +570,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (status == 'approved' || (jwt == null && _verificationOnly)) {
if (mounted) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
}
@@ -515,13 +580,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
localSessionMessage,
actionPath: actionPath,
);
return;
}
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
return;
@@ -529,14 +594,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (mounted) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
}
} catch (e) {
debugPrint("[Auth] Verification FAILED for token: $token. Error: $e");
if (mounted) {
_showError("Verification failed: $e");
_showError(
tr(
'msg.userfront.login.verification_failed',
fallback: '승인 처리에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
}
}
}
@@ -544,6 +615,18 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
Future<void> _verifyLoginCode(String loginId, String code, {String? pendingRef}) async {
final sanitizedLoginId = loginId.replaceAll(' ', '+');
debugPrint("[Auth] Starting code verification for loginId: $sanitizedLoginId");
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다',
);
final linkLoginMessage = tr(
'msg.userfront.login.link.approved',
fallback: '링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.',
);
try {
final res = await AuthProxyService.verifyLoginCode(
sanitizedLoginId,
@@ -560,7 +643,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
}
@@ -570,22 +653,32 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
localSessionMessage,
actionPath: actionPath,
);
return;
}
if (_verificationOnly) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
return;
}
_markVerificationApproved("링크로 로그인 되었습니다. 잠시 후 로그인 화면으로 이동합니다.",
title: '링크 로그인 완료',
pageTitle: '링크 로그인',
actionLabel: '로그인 화면으로 이동',
_markVerificationApproved(
linkLoginMessage,
title: tr(
'ui.userfront.login.link.title',
fallback: '링크 로그인 완료',
),
pageTitle: tr(
'ui.userfront.login.link.page_title',
fallback: '링크 로그인',
),
actionLabel: tr(
'ui.userfront.login.link.action_label',
fallback: '로그인 화면으로 이동',
),
actionPath: '/signin',
autoRedirect: true,
);
@@ -594,14 +687,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_verificationOnly && mounted) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
}
} catch (e) {
debugPrint("[Auth] Code verification FAILED for loginId: $sanitizedLoginId. Error: $e");
if (mounted) {
_showError("Verification failed: $e");
_showError(
tr(
'msg.userfront.login.verification_failed',
fallback: '승인 처리에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
}
}
}
@@ -610,6 +709,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final sanitized = shortCode.trim().toUpperCase();
if (sanitized.isEmpty) return;
debugPrint("[Auth] Starting short code verification for code: $sanitized");
final approvedMessage = tr(
'msg.userfront.login.verification.approved',
fallback: '승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.',
);
final localSessionMessage = tr(
'msg.userfront.login.verification.approved_local',
fallback: '승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다',
);
try {
final res = await AuthProxyService.verifyLoginShortCode(
sanitized,
@@ -624,7 +731,7 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt == null && status == 'approved') {
if (mounted) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
}
@@ -634,14 +741,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (jwt is String && jwt.isNotEmpty) {
if (hasLocalSession) {
_markVerificationApproved(
"승인 되었습니다. 이 기기는 로그인되어 있는 상태입니다. 원격 창도 로그인이 될 예정입니다",
localSessionMessage,
actionPath: actionPath,
);
return;
}
if (_verificationOnly) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
return;
@@ -652,14 +759,20 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_verificationOnly && mounted) {
_markVerificationApproved(
"승인되었습니다. 로그인은 요청하신 창에서 완료됩니다.",
approvedMessage,
actionPath: actionPath,
);
}
} catch (e) {
debugPrint("[Auth] Short code verification FAILED. Error: $e");
if (mounted) {
_showError("Verification failed: $e");
_showError(
tr(
'msg.userfront.login.verification_failed',
fallback: '승인 처리에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
}
}
}
@@ -682,7 +795,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final input = _passwordLoginIdController.text.trim();
final password = _passwordController.text.trim();
if (input.isEmpty || password.isEmpty) {
_showError("이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.");
_showError(
tr(
'msg.userfront.login.password.missing_credentials',
fallback: '이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요.',
),
);
return;
}
@@ -721,7 +839,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (e.toString().contains("User not registered")) {
_showUnregisteredDialog();
} else {
_showError("로그인 실패: ${e.toString().replaceFirst("Exception: ", "")}");
_showError(
tr(
'msg.userfront.login.password.failed',
fallback: '로그인 실패: {{error}}',
params: {
'error': e.toString().replaceFirst('Exception: ', ''),
},
),
);
}
}
}
@@ -746,7 +872,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (e.toString().contains("User not registered")) {
_showUnregisteredDialog();
} else {
_showError("오류: $e");
_showError(
tr(
'msg.userfront.login.link_failed',
fallback: '오류: {{error}}',
params: {'error': e.toString()},
),
);
}
}
}
@@ -782,9 +914,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
});
Navigator.of(context).pop();
_showInfo(isEmail
? "입력하신 이메일로 로그인 링크를 보냈습니다."
: "입력하신 번호로 로그인 링크를 보냈습니다.");
_showInfo(
isEmail
? tr(
'msg.userfront.login.link_sent_email',
fallback: '입력하신 이메일로 로그인 링크를 보냈습니다.',
)
: tr(
'msg.userfront.login.link_sent_phone',
fallback: '입력하신 번호로 로그인 링크를 보냈습니다.',
),
);
final initialInterval = (interval is int && interval > 0)
? Duration(seconds: interval)
@@ -806,7 +946,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (e.toString().contains("User not registered")) {
_showUnregisteredDialog();
} else {
_showError("전송 실패: $e");
_showError(
tr(
'msg.userfront.login.link_send_failed',
fallback: '전송 실패: {{error}}',
params: {'error': e.toString()},
),
);
}
}
}
@@ -842,7 +988,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (result['error'] == 'expired_token') {
if (mounted) {
Navigator.of(context).pop();
_showError("Login timed out.");
_showError(
tr(
'msg.userfront.login.link_timeout',
fallback: '로그인 요청 시간이 초과되었습니다.',
),
);
}
return;
}
@@ -862,7 +1013,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (mounted && Navigator.canPop(context)) {
Navigator.of(context).pop();
}
_showError("로그인 토큰을 확인할 수 없습니다.");
_showError(
tr(
'msg.userfront.login.token_missing',
fallback: '로그인 토큰을 확인할 수 없습니다.',
),
);
return;
}
} catch (e) {
@@ -873,7 +1029,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (mounted) {
debugPrint("[Auth] Polling timed out for ref: $pendingRef");
Navigator.of(context).pop();
_showError("Login timed out.");
_showError(
tr(
'msg.userfront.login.link_timeout',
fallback: '로그인 요청 시간이 초과되었습니다.',
),
);
}
}
@@ -950,7 +1111,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
return;
}
} catch (e) {
_showError("OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.");
_showError(
tr(
'msg.userfront.login.oidc_failed',
fallback: 'OIDC 로그인 처리에 실패했습니다. 다시 시도해 주세요.',
),
);
return;
}
}
@@ -978,12 +1144,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text("미등록 회원"),
content: const Text("가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요."),
title: Text(
tr('ui.userfront.login.unregistered.title', fallback: '미등록 회원'),
),
content: Text(
tr(
'msg.userfront.login.unregistered.body',
fallback: '가입되지 않은 정보입니다.\n회원가입 후 이용해 주세요.',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text("취소"),
child: Text(tr('ui.common.cancel', fallback: '취소')),
),
FilledButton(
onPressed: () {
@@ -991,7 +1164,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
_resetLinkLoginState();
context.push('/signup');
},
child: const Text("회원가입 하기"),
child: Text(
tr('ui.userfront.login.unregistered.action', fallback: '회원가입 하기'),
),
),
],
),
@@ -1028,8 +1203,8 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"Baron 로그인",
style: TextStyle(
tr('ui.userfront.app_title', fallback: 'Baron 로그인'),
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
@@ -1045,13 +1220,19 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
border: Border.all(color: const Color(0xFFFFC107)),
),
child: Row(
children: const [
Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
SizedBox(width: 8),
children: [
const Icon(Icons.warning_amber_rounded, color: Color(0xFF8A6D3B)),
const SizedBox(width: 8),
Expanded(
child: Text(
"drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.",
style: TextStyle(color: Color(0xFF8A6D3B), fontSize: 12),
tr(
'msg.userfront.login.dry_send',
fallback: 'drySend 모드: 실제 이메일/SMS는 발송되지 않습니다.',
),
style: const TextStyle(
color: Color(0xFF8A6D3B),
fontSize: 12,
),
),
),
],
@@ -1062,10 +1243,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
TabBar(
controller: _tabController,
tabs: const [
Tab(text: "비밀번호"),
Tab(text: "로그인 링크"),
Tab(text: "QR 코드"),
tabs: [
Tab(
text: tr(
'ui.userfront.login.tabs.password',
fallback: '비밀번호',
),
),
Tab(
text: tr(
'ui.userfront.login.tabs.link',
fallback: '로그인 링크',
),
),
Tab(
text: tr(
'ui.userfront.login.tabs.qr',
fallback: 'QR 코드',
),
),
],
),
const SizedBox(height: 24),
@@ -1081,10 +1277,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
children: [
TextField(
controller: _passwordLoginIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.field.login_id',
fallback: '이메일 또는 휴대폰 번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
@@ -1092,10 +1291,13 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: "비밀번호",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.lock_outline),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.field.password',
fallback: '비밀번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
),
onSubmitted: (_) => _handlePasswordLogin(),
),
@@ -1105,7 +1307,9 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: const Text("로그인"),
child: Text(
tr('ui.userfront.login.action.submit', fallback: '로그인'),
),
),
],
),
@@ -1118,11 +1322,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
if (_linkPendingRef == null) ...[
TextField(
controller: _linkIdController,
decoration: const InputDecoration(
labelText: "이메일 또는 휴대폰 번호",
hintText: "",
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.person_outline),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.field.login_id',
fallback: '이메일 또는 휴대폰 번호',
),
hintText: '',
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.person_outline),
),
onSubmitted: (_) => _handleLinkLogin(),
),
@@ -1132,19 +1339,30 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(50),
),
child: const Text("로그인 링크 전송"),
child: Text(
tr(
'ui.userfront.login.link.send',
fallback: '로그인 링크 전송',
),
),
),
const SizedBox(height: 24),
const Text(
"입력하신 정보로 로그인 링크를 전송합니다.",
style: TextStyle(color: Colors.grey, fontSize: 12),
Text(
tr(
'msg.userfront.login.link.helper',
fallback: '입력하신 정보로 로그인 링크를 전송합니다.',
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center,
),
],
if (_linkPendingRef != null) ...[
const Text(
"링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.",
style: TextStyle(color: Colors.grey, fontSize: 12),
Text(
tr(
'msg.userfront.login.link.short_code_help',
fallback: '링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다.',
),
style: const TextStyle(color: Colors.grey, fontSize: 12),
textAlign: TextAlign.center,
),
const SizedBox(height: 12),
@@ -1155,11 +1373,14 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
child: TextField(
controller: _shortCodePrefixController,
textCapitalization: TextCapitalization.characters,
decoration: const InputDecoration(
labelText: "영문 2자리",
border: OutlineInputBorder(),
hintText: "AB",
hintStyle: TextStyle(color: Colors.grey),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.login.short_code.prefix',
fallback: '영문 2자리',
),
border: const OutlineInputBorder(),
hintText: 'AB',
hintStyle: const TextStyle(color: Colors.grey),
),
maxLength: 2,
),
@@ -1171,12 +1392,21 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
controller: _shortCodeDigitsController,
keyboardType: TextInputType.number,
decoration: InputDecoration(
labelText: "숫자 6자리",
labelText: tr(
'ui.userfront.login.short_code.digits',
fallback: '숫자 6자리',
),
border: const OutlineInputBorder(),
hintText: "345678",
hintText: '345678',
hintStyle: const TextStyle(color: Colors.grey),
suffixText: _linkExpireSeconds > 0
? "유효시간 ${_formatTime(_linkExpireSeconds)}"
? tr(
'ui.userfront.login.short_code.expire_time',
fallback: '유효시간 {{time}}',
params: {
'time': _formatTime(_linkExpireSeconds),
},
)
: null,
),
maxLength: 6,
@@ -1190,7 +1420,12 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
final prefix = _shortCodePrefixController.text.trim().toUpperCase();
final digits = _shortCodeDigitsController.text.trim();
if (prefix.length != 2 || digits.length != 6) {
_showError("문자 2개와 숫자 6자리를 입력해 주세요.");
_showError(
tr(
'msg.userfront.login.short_code.invalid',
fallback: '문자 2개와 숫자 6자리를 입력해 주세요.',
),
);
return;
}
_verifyShortCode(prefix + digits);
@@ -1198,18 +1433,36 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(45),
),
child: const Text("코드로 로그인"),
child: Text(
tr(
'ui.userfront.login.short_code.submit',
fallback: '코드로 로그인',
),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: () {
if (_linkResendSeconds > 0) {
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
_showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
},
),
);
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
if (loginId.isEmpty) {
_showError("이메일 또는 휴대폰 번호를 입력해 주세요.");
_showError(
tr(
'msg.userfront.login.link.missing_login_id',
fallback: '이메일 또는 휴대폰 번호를 입력해 주세요.',
),
);
return;
}
_startEnchantedFlow(
@@ -1220,8 +1473,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
},
child: Text(
_linkResendSeconds > 0
? "재발송 (${_formatTime(_linkResendSeconds)})"
: "재발송",
? tr(
'ui.userfront.login.link.resend_with_time',
fallback: '재발송 ({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
},
)
: tr(
'ui.common.resend',
fallback: '재발송',
),
),
),
if (!_lastLinkIsEmail) ...[
@@ -1229,12 +1491,25 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
TextButton(
onPressed: () {
if (_linkResendSeconds > 0) {
_showInfo("재발송은 ${_formatTime(_linkResendSeconds)} 후 가능합니다.");
_showInfo(
tr(
'msg.userfront.login.link.resend_wait',
fallback: '재발송은 {{time}} 후 가능합니다.',
params: {
'time': _formatTime(_linkResendSeconds),
},
),
);
return;
}
final loginId = _lastLinkLoginId ?? _linkIdController.text.trim();
if (loginId.isEmpty) {
_showError("휴대폰 번호를 입력해 주세요.");
_showError(
tr(
'msg.userfront.login.link.missing_phone',
fallback: '휴대폰 번호를 입력해 주세요.',
),
);
return;
}
_startEnchantedFlow(
@@ -1243,7 +1518,15 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
codeOnly: true,
);
},
child: Text("코드만 받기(${_formatTime(_linkResendSeconds)})"),
child: Text(
tr(
'ui.userfront.login.link.code_only',
fallback: '코드만 받기({{time}})',
params: {
'time': _formatTime(_linkResendSeconds),
},
),
),
),
],
],
@@ -1276,8 +1559,17 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
const SizedBox(height: 12),
Text(
_qrRemainingSeconds > 0
? "남은 시간: ${_formatTime(_qrRemainingSeconds)}"
: "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,
@@ -1285,19 +1577,33 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
),
),
const SizedBox(height: 8),
const Text(
"모바일 앱으로 스캔하세요",
Text(
tr(
'msg.userfront.login.qr.scan_hint',
fallback: '모바일 앱으로 스캔하세요',
),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 12),
style: const TextStyle(color: Colors.grey, fontSize: 12),
),
TextButton(
onPressed: _startQrFlow,
child: const Text("QR 코드 새로고침")
child: Text(
tr(
'ui.userfront.login.qr.refresh',
fallback: 'QR 코드 새로고침',
),
),
),
],
)
else
const Text("QR 코드를 불러오지 못했습니다.", textAlign: TextAlign.center),
Text(
tr(
'msg.userfront.login.qr.load_failed',
fallback: 'QR 코드를 불러오지 못했습니다.',
),
textAlign: TextAlign.center,
),
],
),
],
@@ -1308,15 +1614,31 @@ class _LoginScreenState extends ConsumerState<LoginScreen>
children: [
TextButton(
onPressed: () => context.push('/forgot-password'),
child: const Text("비밀번호를 잊으셨나요?"),
child: Text(
tr(
'ui.userfront.login.forgot_password',
fallback: '비밀번호를 잊으셨나요?',
),
),
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("계정이 없으신가요?", style: TextStyle(color: Colors.grey, fontSize: 14)),
Text(
tr(
'msg.userfront.login.no_account',
fallback: '계정이 없으신가요?',
),
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
TextButton(
onPressed: () => context.push('/signup'),
child: const Text("회원가입"),
child: Text(
tr(
'ui.userfront.login.signup',
fallback: '회원가입',
),
),
),
],
),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:userfront/i18n.dart';
class LoginSuccessScreen extends StatelessWidget {
const LoginSuccessScreen({super.key});
@@ -16,17 +17,17 @@ class LoginSuccessScreen extends StatelessWidget {
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,
),
),
const SizedBox(height: 16),
const Text(
"성공적으로 로그인되었습니다.",
Text(
tr('msg.userfront.login_success.subtitle', fallback: '성공적으로 로그인되었습니다.'),
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey, fontSize: 16),
style: const TextStyle(color: Colors.grey, fontSize: 16),
),
const SizedBox(height: 48),
@@ -36,7 +37,9 @@ class LoginSuccessScreen extends StatelessWidget {
context.push('/scan');
},
icon: const Icon(Icons.camera_alt, size: 28),
label: const Text("QR 인증 (카메라 켜기)"),
label: Text(
tr('ui.userfront.login_success.qr', fallback: 'QR 인증 (카메라 켜기)'),
),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(80), // 버튼 높이를 더 크게
backgroundColor: Colors.blue.shade700,
@@ -51,7 +54,13 @@ class LoginSuccessScreen extends StatelessWidget {
onPressed: () {
context.go('/');
},
child: const Text("나중에 하기 (대시보드로 이동)", style: TextStyle(color: Colors.grey)),
child: Text(
tr(
'ui.userfront.login_success.later',
fallback: '나중에 하기 (대시보드로 이동)',
),
style: const TextStyle(color: Colors.grey),
),
),
],
),

View File

@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import 'package:logging/logging.dart';
import '../../../core/services/auth_proxy_service.dart';
import '../../../core/services/auth_token_store.dart';
import 'package:userfront/i18n.dart';
class QRScanScreen extends StatefulWidget {
const QRScanScreen({super.key});
@@ -143,7 +144,10 @@ class _QRScanScreenState extends State<QRScanScreen> {
if (mounted) {
setState(() {
_isSuccess = true;
_resultMessage = 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.';
_resultMessage = tr(
'msg.userfront.qr.approve_success',
fallback: 'QR 승인 완료! PC 화면에서 로그인이 진행됩니다.',
);
_isProcessing = false;
});
}
@@ -152,7 +156,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
if (mounted) {
setState(() {
_isSuccess = false;
_resultMessage = 'QR 승인 실패: $e';
_resultMessage = tr(
'msg.userfront.qr.approve_error',
fallback: 'QR 승인 실패: {{error}}',
params: {'error': '$e'},
);
_isProcessing = false;
});
}
@@ -181,8 +189,13 @@ class _QRScanScreenState extends State<QRScanScreen> {
_log.warning('Camera permission request failed: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.'),
SnackBar(
content: Text(
tr(
'msg.userfront.qr.permission_error',
fallback: '카메라 권한 요청에 실패했습니다. 브라우저/OS 설정을 확인해주세요.',
),
),
backgroundColor: Colors.red,
),
);
@@ -198,7 +211,9 @@ class _QRScanScreenState extends State<QRScanScreen> {
final success = _isSuccess == true;
final icon = success ? Icons.check_circle_outline : Icons.error_outline;
final color = success ? Colors.green : Colors.red;
final title = success ? '승인 완료' : '승인 실패';
final title = success
? tr('ui.userfront.qr.result_success', fallback: '승인 완료')
: tr('ui.userfront.qr.result_failure', fallback: '승인 실패');
final message = _resultMessage ?? '';
return Center(
@@ -223,12 +238,12 @@ class _QRScanScreenState extends State<QRScanScreen> {
if (!success)
FilledButton(
onPressed: _resetScan,
child: const Text('다시 스캔'),
child: Text(tr('ui.userfront.qr.rescan', fallback: '다시 스캔')),
),
if (success)
FilledButton(
onPressed: () => context.pop(),
child: const Text('닫기'),
child: Text(tr('ui.common.close', fallback: '닫기')),
),
],
),
@@ -240,7 +255,7 @@ class _QRScanScreenState extends State<QRScanScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Scan QR Code'),
title: Text(tr('ui.userfront.qr.title', fallback: 'Scan QR Code')),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => context.pop(),
@@ -263,8 +278,15 @@ class _QRScanScreenState extends State<QRScanScreen> {
const SizedBox(height: 10),
Text(
isPermissionDenied
? '카메라 권한이 필요합니다.'
: '카메라 오류: ${error.errorCode}',
? tr(
'msg.userfront.qr.permission_required',
fallback: '카메라 권한이 필요합니다.',
)
: tr(
'msg.userfront.qr.camera_error',
fallback: '카메라 오류: {{error}}',
params: {'error': '${error.errorCode}'},
),
),
const SizedBox(height: 12),
FilledButton(
@@ -273,8 +295,11 @@ class _QRScanScreenState extends State<QRScanScreen> {
: _requestCameraPermission,
child: Text(
_isRequestingCamera
? '요청 중...'
: '카메라 권한 요청하기',
? tr('ui.common.requesting', fallback: '요청 중...')
: tr(
'ui.userfront.qr.request_permission',
fallback: '카메라 권한 요청하기',
),
),
),
],

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../../../core/services/auth_proxy_service.dart';
import 'package:userfront/i18n.dart';
class ResetPasswordScreen extends StatefulWidget {
final String? loginId; // Now receiving loginId
@@ -66,7 +67,12 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Future<void> _handlePasswordReset() async {
if (_formKey.currentState?.validate() != true) return;
if ((_loginId == null || _loginId!.isEmpty) && (_token == null || _token!.isEmpty)) {
_showError("유효하지 않은 재설정 링크입니다. (loginId/token 누락)");
_showError(
tr(
'msg.userfront.reset.invalid_link',
fallback: '유효하지 않은 재설정 링크입니다. (loginId/token 누락)',
),
);
return;
}
@@ -81,8 +87,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text("비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요."),
SnackBar(
content: Text(
tr(
'msg.userfront.reset.success',
fallback: '비밀번호가 성공적으로 변경되었습니다. 다시 로그인해주세요.',
),
),
backgroundColor: Colors.green,
),
);
@@ -90,7 +101,13 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
}
} catch (e) {
if (mounted) {
_showError("비밀번호 변경에 실패했습니다: ${e.toString()}");
_showError(
tr(
'msg.userfront.reset.error.generic',
fallback: '비밀번호 변경에 실패했습니다: {{error}}',
params: {'error': e.toString()},
),
);
}
} finally {
if (mounted) {
@@ -107,7 +124,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
String _buildPolicyDescription() {
if (_isPolicyLoading) {
return "비밀번호 정책을 불러오는 중입니다...";
return tr(
'msg.userfront.reset.policy_loading',
fallback: '비밀번호 정책을 불러오는 중입니다...',
);
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -116,14 +136,42 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final requiresNumber = _policy?['number'] ?? true;
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>["최소 ${minLength}자 이상"];
final parts = <String>[
tr(
'msg.userfront.reset.policy.min_length',
fallback: '최소 {{count}}자 이상',
params: {'count': '$minLength'},
),
];
if (minTypes > 0) {
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상");
parts.add(
tr(
'msg.userfront.reset.policy.min_types',
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
params: {'count': '$minTypes'},
),
);
}
if (requiresLower) {
parts.add(
tr('msg.userfront.reset.policy.lowercase', fallback: '소문자 1개 이상'),
);
}
if (requiresUpper) {
parts.add(
tr('msg.userfront.reset.policy.uppercase', fallback: '대문자 1개 이상'),
);
}
if (requiresNumber) {
parts.add(
tr('msg.userfront.reset.policy.number', fallback: '숫자 1개 이상'),
);
}
if (requiresSymbol) {
parts.add(
tr('msg.userfront.reset.policy.symbol', fallback: '특수문자 1개 이상'),
);
}
if (requiresLower) parts.add("소문자 1개 이상");
if (requiresUpper) parts.add("대문자 1개 이상");
if (requiresNumber) parts.add("숫자 1개 이상");
if (requiresSymbol) parts.add("특수문자 1개 이상");
return parts.join(", ");
}
@@ -132,7 +180,9 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("새 비밀번호 설정"),
title: Text(
tr('ui.userfront.reset.title', fallback: '새 비밀번호 설정'),
),
centerTitle: true,
),
body: Center(
@@ -148,7 +198,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
"새로운 비밀번호 설정",
tr(
'ui.userfront.reset.subtitle',
fallback: '새로운 비밀번호 설정',
),
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
@@ -166,7 +219,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
controller: _passwordController,
obscureText: _isPasswordObscured,
decoration: InputDecoration(
labelText: "새 비밀번호",
labelText: tr(
'ui.userfront.reset.new_password',
fallback: '새 비밀번호',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
@@ -183,11 +239,18 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
validator: (value) {
final val = value ?? "";
if (val.isEmpty) {
return '비밀번호를 입력해주세요.';
return tr(
'msg.userfront.reset.error.empty_password',
fallback: '비밀번호를 입력해주세요.',
);
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
if (val.length < minLength) {
return '비밀번호는 최소 $minLength자 이상이어야 합니다.';
return tr(
'msg.userfront.reset.error.min_length',
fallback: '비밀번호는 최소 {{count}}자 이상이어야 합니다.',
params: {'count': '$minLength'},
);
}
final hasLower = RegExp(r'[a-z]').hasMatch(val);
final hasUpper = RegExp(r'[A-Z]').hasMatch(val);
@@ -201,20 +264,37 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
if (minTypes > 0 && typeCount < minTypes) {
return '비밀번호는 영문 대/소문자/숫자/특수문자 중 $minTypes가지 이상 포함해야 합니다.';
return tr(
'msg.userfront.reset.error.min_types',
fallback:
'비밀번호는 영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상 포함해야 합니다.',
params: {'count': '$minTypes'},
);
}
if ((_policy?['lowercase'] ?? true) && !hasLower) {
return '최소 1개 이상의 소문자를 포함해야 합니다.';
return tr(
'msg.userfront.reset.error.lowercase',
fallback: '최소 1개 이상의 소문자를 포함해야 합니다.',
);
}
if ((_policy?['uppercase'] ?? false) && !hasUpper) {
return '최소 1개 이상의 대문자를 포함해야 합니다.';
return tr(
'msg.userfront.reset.error.uppercase',
fallback: '최소 1개 이상의 대문자를 포함해야 합니다.',
);
}
if ((_policy?['number'] ?? true) && !hasNumber) {
return '최소 1개 이상의 숫자를 포함해야 합니다.';
return tr(
'msg.userfront.reset.error.number',
fallback: '최소 1개 이상의 숫자를 포함해야 합니다.',
);
}
if ((_policy?['nonAlphanumeric'] ?? true) && !hasSymbol) {
return '최소 1개 이상의 특수문자를 포함해야 합니다.';
return tr(
'msg.userfront.reset.error.symbol',
fallback: '최소 1개 이상의 특수문자를 포함해야 합니다.',
);
}
return null;
},
@@ -224,7 +304,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
controller: _confirmPasswordController,
obscureText: _isConfirmPasswordObscured,
decoration: InputDecoration(
labelText: "새 비밀번호 확인",
labelText: tr(
'ui.userfront.reset.confirm_password',
fallback: '새 비밀번호 확인',
),
border: const OutlineInputBorder(),
prefixIcon: const Icon(Icons.lock_outline),
suffixIcon: IconButton(
@@ -240,7 +323,10 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
),
validator: (value) {
if (value != _passwordController.text) {
return '비밀번호가 일치하지 않습니다.';
return tr(
'msg.userfront.reset.error.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
);
}
return null;
},
@@ -255,9 +341,17 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text("비밀번호 변경"),
: Text(
tr(
'ui.userfront.reset.submit',
fallback: '비밀번호 변경',
),
),
),
],
),
@@ -268,20 +362,24 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
}
Widget _buildInvalidTokenView() {
return const Center(
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, color: Colors.red, size: 60),
SizedBox(height: 16),
const Icon(Icons.error_outline, color: Colors.red, size: 60),
const SizedBox(height: 16),
Text(
"유효하지 않은 링크입니다.",
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
tr('msg.userfront.reset.invalid_title',
fallback: '유효하지 않은 링크입니다.'),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
),
SizedBox(height: 8),
const SizedBox(height: 8),
Text(
"비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.",
tr(
'msg.userfront.reset.invalid_body',
fallback: '비밀번호 재설정 링크가 만료되었거나 잘못되었습니다. 다시 시도해주세요.',
),
textAlign: TextAlign.center,
),
],

View File

@@ -2,6 +2,7 @@ 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/services/auth_proxy_service.dart';
class SignupScreen extends StatefulWidget {
@@ -130,8 +131,11 @@ class _SignupScreenState extends State<SignupScreen> {
_emailTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return;
setState(() {
if (_emailSeconds > 0) _emailSeconds--;
else timer.cancel();
if (_emailSeconds > 0) {
_emailSeconds--;
} else {
timer.cancel();
}
});
});
} else {
@@ -140,8 +144,11 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneTimer = Timer.periodic(const Duration(seconds: 1), (timer) {
if (!mounted) return;
setState(() {
if (_phoneSeconds > 0) _phoneSeconds--;
else timer.cancel();
if (_phoneSeconds > 0) {
_phoneSeconds--;
} else {
timer.cancel();
}
});
});
}
@@ -157,20 +164,30 @@ 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 = '유효한 이메일 형식이 아닙니다.');
setState(() => _emailError = tr(
'msg.userfront.signup.email.invalid',
fallback: '유효한 이메일 형식이 아닙니다.',
));
return;
}
setState(() { _isLoading = true; _emailError = null; });
try {
final available = await AuthProxyService.checkEmailAvailability(email);
if (!available) {
setState(() => _emailError = '이미 가입된 이메일입니다.');
setState(() => _emailError = tr(
'msg.userfront.signup.email.duplicate',
fallback: '이미 가입된 이메일입니다.',
));
return;
}
await AuthProxyService.sendSignupCode(email, 'email');
_startTimer('email');
} catch (e) {
setState(() => _emailError = '발송 실패: $e');
setState(() => _emailError = tr(
'msg.userfront.signup.email.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
} finally {
setState(() => _isLoading = false);
}
@@ -189,10 +206,17 @@ class _SignupScreenState extends State<SignupScreen> {
_emailError = null;
});
} else {
setState(() => _emailError = '인증코드가 일치하지 않습니다.');
setState(() => _emailError = tr(
'msg.userfront.signup.email.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
}
} catch (e) {
setState(() => _emailError = '인증 실패: $e');
setState(() => _emailError = tr(
'msg.userfront.signup.email.verify_failed',
fallback: '인증 실패: {{error}}',
params: {'error': e.toString()},
));
}
}
@@ -204,7 +228,11 @@ class _SignupScreenState extends State<SignupScreen> {
await AuthProxyService.sendSignupCode(phone, 'phone');
_startTimer('phone');
} catch (e) {
setState(() => _phoneError = '발송 실패: $e');
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.send_failed',
fallback: '발송 실패: {{error}}',
params: {'error': e.toString()},
));
} finally {
setState(() => _isLoading = false);
}
@@ -223,16 +251,26 @@ class _SignupScreenState extends State<SignupScreen> {
_phoneError = null;
});
} else {
setState(() => _phoneError = '인증코드가 일치하지 않습니다.');
setState(() => _phoneError = tr(
'msg.userfront.signup.phone.code_mismatch',
fallback: '인증코드가 일치하지 않습니다.',
));
}
} catch (e) {
setState(() => _phoneError = '인증 실패: $e');
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 = '비밀번호가 일치하지 않습니다.');
setState(() => _confirmPasswordError = tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
));
return;
}
if (!_formKey.currentState!.validate()) return;
@@ -257,12 +295,38 @@ class _SignupScreenState extends State<SignupScreen> {
} catch (e) {
String eStr = e.toString().toLowerCase();
setState(() {
if (eStr.contains('uppercase')) _passwordError = '대문자가 최소 1개 이상 포함되어야 합니다.';
else if (eStr.contains('lowercase')) _passwordError = '소문자가 최소 1개 이상 포함되어야 합니다.';
else if (eStr.contains('digit') || eStr.contains('number')) _passwordError = '숫자가 최소 1개 이상 포함되어야 합니다.';
else if (eStr.contains('symbol') || eStr.contains('special')) _passwordError = '특수문자가 최소 1개 이상 포함되어야 합니다.';
else if (eStr.contains('length') || eStr.contains('12 characters')) _passwordError = '비밀번호는 최소 12자 이상이어야 합니다.';
else _passwordError = '가입 실패: $e';
if (eStr.contains('uppercase')) {
_passwordError = tr(
'msg.userfront.signup.password.uppercase_required',
fallback: '문자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('lowercase')) {
_passwordError = tr(
'msg.userfront.signup.password.lowercase_required',
fallback: '소문자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('digit') || eStr.contains('number')) {
_passwordError = tr(
'msg.userfront.signup.password.number_required',
fallback: '숫자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('symbol') || eStr.contains('special')) {
_passwordError = tr(
'msg.userfront.signup.password.symbol_required',
fallback: '특수문자가 최소 1개 이상 포함되어야 합니다.',
);
} else if (eStr.contains('length') || eStr.contains('12 characters')) {
_passwordError = tr(
'msg.userfront.signup.password.length_required',
fallback: '비밀번호는 최소 12자 이상이어야 합니다.',
);
} else {
_passwordError = tr(
'msg.userfront.signup.failed',
fallback: '가입 실패: {{error}}',
params: {'error': e.toString()},
);
}
});
} finally {
setState(() => _isLoading = false);
@@ -274,9 +338,20 @@ class _SignupScreenState extends State<SignupScreen> {
context: context,
barrierDismissible: false,
builder: (context) => AlertDialog(
title: const Text('회원가입 완료'),
content: const Text('성공적으로 가입되었습니다.'),
actions: [TextButton(onPressed: () => context.go('/signin'), child: const Text('로그인하기'))],
title: Text(
tr('msg.userfront.signup.success.title', fallback: '회원가입 완료'),
),
content: Text(
tr('msg.userfront.signup.success.body', fallback: '성공적으로 가입되었습니다.'),
),
actions: [
TextButton(
onPressed: () => context.go('/signin'),
child: Text(
tr('ui.userfront.signup.success.action', fallback: '로그인하기'),
),
),
],
),
);
}
@@ -288,13 +363,25 @@ class _SignupScreenState extends State<SignupScreen> {
padding: const EdgeInsets.symmetric(vertical: 20),
child: Row(
children: [
_stepCircle(1, '약관동의'),
_stepCircle(
1,
tr('ui.userfront.signup.steps.agreement', fallback: '약관동의'),
),
_stepLine(1),
_stepCircle(2, '본인인증'),
_stepCircle(
2,
tr('ui.userfront.signup.steps.verify', fallback: '본인인증'),
),
_stepLine(2),
_stepCircle(3, '정보입력'),
_stepCircle(
3,
tr('ui.userfront.signup.steps.profile', fallback: '정보입력'),
),
_stepLine(3),
_stepCircle(4, '비밀번호'),
_stepCircle(
4,
tr('ui.userfront.signup.steps.password', fallback: '비밀번호'),
),
],
),
);
@@ -330,9 +417,17 @@ class _SignupScreenState extends State<SignupScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('서비스 이용을 위해\n약관에 동의해주세요',
style: TextStyle(
fontSize: 20, fontWeight: FontWeight.bold, height: 1.3)),
Text(
tr(
'msg.userfront.signup.agreement.title',
fallback: '서비스 이용을 위해\n약관에 동의해주세요',
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.3,
),
),
const SizedBox(height: 24),
// 모두 동의 버튼
Container(
@@ -342,8 +437,13 @@ class _SignupScreenState extends State<SignupScreen> {
border: Border.all(color: Colors.grey[200]!),
),
child: CheckboxListTile(
title: const Text('모두 동의합니다',
style: TextStyle(fontSize: 15, fontWeight: FontWeight.bold)),
title: Text(
tr(
'ui.userfront.signup.agreement.all',
fallback: '모두 동의합니다',
),
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold),
),
value: _termsAccepted && _privacyAccepted,
onChanged: (val) {
setState(() {
@@ -357,14 +457,20 @@ class _SignupScreenState extends State<SignupScreen> {
),
const SizedBox(height: 16),
_agreementSection(
title: '바론 소프트웨어 이용약관 (필수)',
title: tr(
'ui.userfront.signup.agreement.tos_title',
fallback: '바론 소프트웨어 이용약관 (필수)',
),
content: _tosText,
value: _termsAccepted,
onChanged: (val) => setState(() => _termsAccepted = val!),
),
const SizedBox(height: 16),
_agreementSection(
title: '개인정보 수집 및 이용 동의 (필수)',
title: tr(
'ui.userfront.signup.agreement.privacy_title',
fallback: '개인정보 수집 및 이용 동의 (필수)',
),
content: _privacyText,
value: _privacyAccepted,
onChanged: (val) => setState(() => _privacyAccepted = val!),
@@ -410,7 +516,9 @@ class _SignupScreenState extends State<SignupScreen> {
);
}
static const String _tosText = """
static String get _tosText => tr(
'msg.userfront.signup.tos_full',
fallback: """
바론 소프트웨어 이용약관
제1장 총칙
@@ -480,9 +588,12 @@ class _SignupScreenState extends State<SignupScreen> {
본 약관에 따른 분쟁은 서울중앙지방법원을 관할 법원으로 합니다.
부칙
본 약관은 2024년 10월 1일부터 시행됩니다.
""";
""",
);
static const String _privacyText = """
static String get _privacyText => tr(
'msg.userfront.signup.privacy_full',
fallback: """
개인정보 수집 및 이용 동의
바론서비스 개인정보처리방침
@@ -590,33 +701,46 @@ class _SignupScreenState extends State<SignupScreen> {
회사는 이용자의 개인정보를 국외로 이전하지 않으며, 향후 필요한 경우, 사전에 이용자의 동의를 받습니다.
제8조 (기타)
본 방침에 명시되지 않은 사항은 회사의 내부 방침과 관련 법령에 따릅니다.
""";
""",
);
Widget _buildStepAuth() {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('본인 확인을 위해\n인증을 진행해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(
tr(
'msg.userfront.signup.auth.title',
fallback: '본인 확인을 위해\n인증을 진행해주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// 가족사 이메일 안내 문구
Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(color: Colors.blue[50], borderRadius: BorderRadius.circular(6)),
child: const Row(
child: Row(
children: [
Icon(Icons.info_outline, size: 16, color: Colors.blue),
SizedBox(width: 8),
const Icon(Icons.info_outline, size: 16, color: Colors.blue),
const SizedBox(width: 8),
Expanded(
child: Text(
'가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
style: TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
tr(
'msg.userfront.signup.auth.affiliate_notice',
fallback: '가족사 회원의 경우 반드시 회사 공식 이메일을 입력해주세요.',
),
style: const TextStyle(fontSize: 12, color: Colors.blue, fontWeight: FontWeight.w500),
),
),
],
),
),
const SizedBox(height: 24),
Text('이메일 인증', style: const TextStyle(fontWeight: FontWeight.bold)),
Text(
tr('ui.userfront.signup.auth.email.title', fallback: '이메일 인증'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
@@ -625,7 +749,10 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _emailController,
onChanged: _checkEmailAffiliation, // 도메인 실시간 체크
decoration: InputDecoration(
labelText: '이메일 주소',
labelText: tr(
'ui.userfront.signup.auth.email.label',
fallback: '이메일 주소',
),
border: const OutlineInputBorder(),
errorText: _emailError,
hintText: 'example@hanmaceng.co.kr',
@@ -639,7 +766,14 @@ class _SignupScreenState extends State<SignupScreen> {
child: ElevatedButton(
onPressed: (_isEmailVerified || _isLoading) ? null : _sendEmailCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
child: Text(_emailSeconds > 0 ? '재발송' : '인증요청'),
child: Text(
_emailSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
: tr(
'ui.userfront.signup.auth.request_code',
fallback: '인증요청',
),
),
),
),
],
@@ -649,7 +783,10 @@ class _SignupScreenState extends State<SignupScreen> {
TextFormField(
controller: _emailCodeController,
decoration: InputDecoration(
labelText: '인증코드 6자리',
labelText: tr(
'ui.userfront.signup.auth.code_label',
fallback: '인증코드 6자리',
),
suffixText: _formatTime(_emailSeconds),
border: const OutlineInputBorder(),
),
@@ -658,19 +795,40 @@ class _SignupScreenState extends State<SignupScreen> {
onChanged: (val) { if(val.length == 6) _verifyEmailCode(); },
),
],
if (_isEmailVerified) const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('✅ 이메일 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)),
),
if (_isEmailVerified)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
tr(
'msg.userfront.signup.email.verified',
fallback: '✅ 이메일 인증 완료',
),
style: const TextStyle(
color: Colors.green,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 24),
Text('휴대폰 인증', style: const TextStyle(fontWeight: FontWeight.bold)),
Text(
tr('ui.userfront.signup.phone.title', fallback: '휴대폰 인증'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: TextFormField(
controller: _phoneController,
decoration: InputDecoration(labelText: '휴대폰 번호 (-없이)', border: const OutlineInputBorder(), errorText: _phoneError),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.phone.label',
fallback: '휴대폰 번호 (-없이)',
),
border: const OutlineInputBorder(),
errorText: _phoneError,
),
readOnly: _isPhoneVerified,
keyboardType: TextInputType.phone,
),
@@ -681,7 +839,14 @@ class _SignupScreenState extends State<SignupScreen> {
child: ElevatedButton(
onPressed: (_isPhoneVerified || _isLoading) ? null : _sendPhoneCode,
style: ElevatedButton.styleFrom(backgroundColor: Colors.grey[100], foregroundColor: Colors.black, elevation: 0),
child: Text(_phoneSeconds > 0 ? '재발송' : '인증요청'),
child: Text(
_phoneSeconds > 0
? tr('ui.common.resend', fallback: '재발송')
: tr(
'ui.userfront.signup.auth.request_code',
fallback: '인증요청',
),
),
),
),
],
@@ -691,7 +856,10 @@ class _SignupScreenState extends State<SignupScreen> {
TextFormField(
controller: _phoneCodeController,
decoration: InputDecoration(
labelText: '인증코드 6자리',
labelText: tr(
'ui.userfront.signup.auth.code_label',
fallback: '인증코드 6자리',
),
suffixText: _formatTime(_phoneSeconds),
border: const OutlineInputBorder(),
),
@@ -700,10 +868,21 @@ class _SignupScreenState extends State<SignupScreen> {
onChanged: (val) { if(val.length == 6) _verifyPhoneCode(); },
),
],
if (_isPhoneVerified) const Padding(
padding: EdgeInsets.only(top: 8),
child: Text('✅ 휴대폰 인증 완료', style: TextStyle(color: Colors.green, fontSize: 13, fontWeight: FontWeight.bold)),
),
if (_isPhoneVerified)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(
tr(
'msg.userfront.signup.phone.verified',
fallback: '✅ 휴대폰 인증 완료',
),
style: const TextStyle(
color: Colors.green,
fontSize: 13,
fontWeight: FontWeight.bold,
),
),
),
],
);
}
@@ -712,12 +891,24 @@ class _SignupScreenState extends State<SignupScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('회원님의\n소속 정보를 알려주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(
tr(
'msg.userfront.signup.profile.title',
fallback: '회원님의\n소속 정보를 알려주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 24),
TextFormField(
controller: _nameController,
onChanged: (_) => setState(() {}),
decoration: const InputDecoration(labelText: '이름', border: OutlineInputBorder()),
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.name',
fallback: '이름',
),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 16),
// 소속 유형 선택 (가족사 메일일 경우 비활성화)
@@ -726,17 +917,51 @@ class _SignupScreenState extends State<SignupScreen> {
child: Opacity(
opacity: _isAffiliateEmail ? 0.7 : 1.0,
child: DropdownButtonFormField<String>(
value: _affiliationType,
key: ValueKey(_affiliationType),
initialValue: _affiliationType,
decoration: InputDecoration(
labelText: '소속 유형',
labelText: tr(
'ui.userfront.signup.profile.affiliation_type',
fallback: '소속 유형',
),
border: const OutlineInputBorder(),
helperText: _isAffiliateEmail ? '가족사 이메일 사용 시 자동으로 선택됩니다.' : null,
helperText: _isAffiliateEmail
? tr(
'msg.userfront.signup.profile.affiliate_hint',
fallback: '가족사 이메일 사용 시 자동으로 선택됩니다.',
)
: null,
),
items: const [
DropdownMenuItem(value: 'GENERAL', child: Text('일반 사용자')),
DropdownMenuItem(value: 'AFFILIATE', child: Text('가족사 임직원')),
items: [
DropdownMenuItem(
value: 'GENERAL',
child: Text(
tr(
'domain.affiliation.general',
fallback: '일반 사용자',
),
),
),
DropdownMenuItem(
value: 'AFFILIATE',
child: Text(
tr(
'domain.affiliation.affiliate',
fallback: '가족사 임직원',
),
),
),
],
onChanged: _isAffiliateEmail ? null : (val) => setState(() { _affiliationType = val!; }),
onChanged: _isAffiliateEmail
? null
: (val) {
if (val == null) {
return;
}
setState(() {
_affiliationType = val;
});
},
),
),
),
@@ -748,17 +973,56 @@ class _SignupScreenState extends State<SignupScreen> {
child: Opacity(
opacity: _isAffiliateEmail ? 0.7 : 1.0,
child: DropdownButtonFormField<String>(
value: _companyCode,
decoration: const InputDecoration(labelText: '가족사 선택', border: OutlineInputBorder()),
items: const [
DropdownMenuItem(value: 'HANMAC', child: Text('한맥')),
DropdownMenuItem(value: 'SAMAN', child: Text('삼안')),
DropdownMenuItem(value: 'PTC', child: Text('PTC')),
DropdownMenuItem(value: 'JANGHEON', child: Text('장헌')),
DropdownMenuItem(value: 'BARON', child: Text('바론')),
DropdownMenuItem(value: 'HALLA', child: Text('한라')),
key: ValueKey(_companyCode ?? 'none'),
initialValue: _companyCode,
decoration: InputDecoration(
labelText: tr(
'ui.userfront.signup.profile.company',
fallback: '가족사 선택',
),
border: const OutlineInputBorder(),
),
items: [
DropdownMenuItem(
value: 'HANMAC',
child: Text(
tr('domain.company.hanmac', fallback: '한맥'),
),
),
DropdownMenuItem(
value: 'SAMAN',
child: Text(
tr('domain.company.saman', fallback: '삼안'),
),
),
DropdownMenuItem(
value: 'PTC',
child: Text(
tr('domain.company.ptc', fallback: 'PTC'),
),
),
DropdownMenuItem(
value: 'JANGHEON',
child: Text(
tr('domain.company.jangheon', fallback: '장헌'),
),
),
DropdownMenuItem(
value: 'BARON',
child: Text(
tr('domain.company.baron', fallback: '바론'),
),
),
DropdownMenuItem(
value: 'HALLA',
child: Text(
tr('domain.company.halla', fallback: '한라'),
),
),
],
onChanged: _isAffiliateEmail ? null : (val) => setState(() => _companyCode = val),
onChanged: _isAffiliateEmail
? null
: (val) => setState(() => _companyCode = val),
),
),
),
@@ -768,7 +1032,12 @@ class _SignupScreenState extends State<SignupScreen> {
controller: _deptController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: _affiliationType == 'AFFILIATE' ? '부서명' : '소속 정보 (선택)',
labelText: _affiliationType == 'AFFILIATE'
? tr('ui.userfront.signup.profile.department', fallback: '부서명')
: tr(
'ui.userfront.signup.profile.department_optional',
fallback: '소속 정보 (선택)',
),
border: const OutlineInputBorder()
),
),
@@ -778,7 +1047,10 @@ class _SignupScreenState extends State<SignupScreen> {
String _buildPolicyDescription() {
if (_isPolicyLoading) {
return "비밀번호 정책을 불러오는 중입니다...";
return tr(
'msg.userfront.signup.policy.loading',
fallback: '비밀번호 정책을 불러오는 중입니다...',
);
}
final minLength = (_policy?['minLength'] as int?) ?? 12;
final minTypes = (_policy?['minCharacterTypes'] as int?) ?? 0;
@@ -787,16 +1059,60 @@ class _SignupScreenState extends State<SignupScreen> {
final requiresNumber = _policy?['number'] ?? true;
final requiresSymbol = _policy?['nonAlphanumeric'] ?? true;
final parts = <String>["최소 $minLength자 이상"];
final parts = <String>[
tr(
'msg.userfront.signup.policy.min_length',
fallback: '최소 {{count}}자 이상',
params: {'count': minLength.toString()},
),
];
if (minTypes > 0) {
parts.add("영문 대/소문자/숫자/특수문자 중 ${minTypes}가지 이상");
parts.add(
tr(
'msg.userfront.signup.policy.min_types',
fallback: '영문 대/소문자/숫자/특수문자 중 {{count}}가지 이상',
params: {'count': minTypes.toString()},
),
);
}
if (requiresUpper) {
parts.add(
tr(
'msg.userfront.signup.policy.uppercase',
fallback: '대문자',
),
);
}
if (requiresLower) {
parts.add(
tr(
'msg.userfront.signup.policy.lowercase',
fallback: '소문자',
),
);
}
if (requiresNumber) {
parts.add(
tr(
'msg.userfront.signup.policy.number',
fallback: '숫자',
),
);
}
if (requiresSymbol) {
parts.add(
tr(
'msg.userfront.signup.policy.symbol',
fallback: '특수문자',
),
);
}
if (requiresUpper) parts.add("대문자");
if (requiresLower) parts.add("소문자");
if (requiresNumber) parts.add("숫자");
if (requiresSymbol) parts.add("특수문자");
return "보안 정책: ${parts.join(', ')}";
return tr(
'msg.userfront.signup.policy.summary',
fallback: '보안 정책: {{rules}}',
params: {'rules': parts.join(', ')},
);
}
Widget _buildStepPassword() {
@@ -825,7 +1141,13 @@ class _SignupScreenState extends State<SignupScreen> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('마지막으로\n비밀번호를 설정해주세요', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text(
tr(
'msg.userfront.signup.password.title',
fallback: '마지막으로\n비밀번호를 설정해주세요',
),
style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
// 비밀번호 정책 안내 박스
Container(
@@ -850,7 +1172,10 @@ class _SignupScreenState extends State<SignupScreen> {
obscureText: true,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: '비밀번호',
labelText: tr(
'ui.userfront.signup.password.label',
fallback: '비밀번호',
),
border: const OutlineInputBorder(),
errorText: _passwordError,
),
@@ -859,12 +1184,55 @@ class _SignupScreenState extends State<SignupScreen> {
Wrap(
spacing: 10,
children: [
_cryptoCheck('$minLength자 이상', hasLength),
if (minTypes > 0) _cryptoCheck('문자 유형 ${minTypes}가지 이상', hasTypeCount),
if (requiresUpper) _cryptoCheck('대문자', hasUpper),
if (requiresLower) _cryptoCheck('소문자', hasLower),
if (requiresNumber) _cryptoCheck('숫자', hasDigit),
if (requiresSymbol) _cryptoCheck('특수문자', hasSpecial),
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.min_length',
fallback: '{{count}}자 이상',
params: {'count': minLength.toString()},
),
hasLength,
),
if (minTypes > 0)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.min_types',
fallback: '문자 유형 {{count}}가지 이상',
params: {'count': minTypes.toString()},
),
hasTypeCount,
),
if (requiresUpper)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.uppercase',
fallback: '대문자',
),
hasUpper,
),
if (requiresLower)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.lowercase',
fallback: '소문자',
),
hasLower,
),
if (requiresNumber)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.number',
fallback: '숫자',
),
hasDigit,
),
if (requiresSymbol)
_cryptoCheck(
tr(
'msg.userfront.signup.password.rule.symbol',
fallback: '특수문자',
),
hasSpecial,
),
],
),
const SizedBox(height: 16),
@@ -873,11 +1241,19 @@ class _SignupScreenState extends State<SignupScreen> {
obscureText: true,
onChanged: (val) {
setState(() {
_confirmPasswordError = (val != _passwordController.text) ? '비밀번호가 일치하지 않습니다.' : null;
_confirmPasswordError = (val != _passwordController.text)
? tr(
'msg.userfront.signup.password.mismatch',
fallback: '비밀번호가 일치하지 않습니다.',
)
: null;
});
},
decoration: InputDecoration(
labelText: '비밀번호 확인',
labelText: tr(
'ui.userfront.signup.password.confirm_label',
fallback: '비밀번호 확인',
),
border: const OutlineInputBorder(),
errorText: _confirmPasswordError,
),
@@ -917,7 +1293,10 @@ class _SignupScreenState extends State<SignupScreen> {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
title: Text('회원가입', style: TextStyle(fontWeight: FontWeight.bold)),
title: Text(
tr('ui.userfront.signup.title', fallback: '회원가입'),
style: const TextStyle(fontWeight: FontWeight.bold),
),
elevation: 0,
backgroundColor: Colors.white,
foregroundColor: Colors.black,
@@ -951,7 +1330,10 @@ class _SignupScreenState extends State<SignupScreen> {
child: OutlinedButton(
onPressed: () => setState(() => _currentStep--),
style: OutlinedButton.styleFrom(minimumSize: const Size.fromHeight(55), side: const BorderSide(color: Colors.black)),
child: const Text('이전', style: TextStyle(color: Colors.black)),
child: Text(
tr('ui.common.prev', fallback: '이전'),
style: const TextStyle(color: Colors.black),
),
),
),
const SizedBox(width: 12),
@@ -967,7 +1349,11 @@ class _SignupScreenState extends State<SignupScreen> {
),
child: _isLoading
? const SizedBox(height: 20, width: 20, child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))
: Text(_currentStep < 4 ? '다음 단계' : '가입 완료'),
: Text(
_currentStep < 4
? tr('ui.userfront.signup.next_step', fallback: '다음 단계')
: tr('ui.userfront.signup.complete', fallback: '가입 완료'),
),
),
),
],