diff --git a/userfront/assets/translations/en.toml b/userfront/assets/translations/en.toml index 538eeb5b..8a92c33a 100644 --- a/userfront/assets/translations/en.toml +++ b/userfront/assets/translations/en.toml @@ -159,6 +159,7 @@ resend_wait = "You can resend in {time}." short_code_help = "You can also sign in with the last 2 letters and 6 digits from the link you received." [msg.userfront.login.password] +caps_lock_on = "Caps Lock is on." failed = "Sign-in failed: {error}" missing_credentials = "Enter both your email or phone number and your password." diff --git a/userfront/assets/translations/ko.toml b/userfront/assets/translations/ko.toml index 18d2b303..8b686a44 100644 --- a/userfront/assets/translations/ko.toml +++ b/userfront/assets/translations/ko.toml @@ -158,6 +158,7 @@ resend_wait = "재발송은 {time} 후 가능합니다." short_code_help = "링크로 받은 값의 뒤 문자 2개와 숫자 6자리를 입력하셔도 로그인 할 수 있습니다." [msg.userfront.login.password] +caps_lock_on = "Caps Lock이 켜져 있습니다." failed = "로그인 실패: {error}" missing_credentials = "이메일(또는 전화번호)와 비밀번호를 모두 입력해주세요." diff --git a/userfront/assets/translations/template.toml b/userfront/assets/translations/template.toml index 44c85800..9659ea55 100644 --- a/userfront/assets/translations/template.toml +++ b/userfront/assets/translations/template.toml @@ -158,6 +158,7 @@ resend_wait = "" short_code_help = "" [msg.userfront.login.password] +caps_lock_on = "" failed = "" missing_credentials = "" diff --git a/userfront/lib/features/auth/presentation/login_screen.dart b/userfront/lib/features/auth/presentation/login_screen.dart index 89365e12..dad569bf 100644 --- a/userfront/lib/features/auth/presentation/login_screen.dart +++ b/userfront/lib/features/auth/presentation/login_screen.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_flutter/qr_flutter.dart'; @@ -43,8 +44,10 @@ class _LoginScreenState extends ConsumerState final TextEditingController _passwordLoginIdController = TextEditingController(); final TextEditingController _passwordController = TextEditingController(); + final FocusNode _passwordFocusNode = FocusNode(); String? _redirectUrl; String? _loginChallenge; + bool _isPasswordCapsLockOn = false; // QR Login Variables String? _qrImageBase64; @@ -93,6 +96,8 @@ class _LoginScreenState extends ConsumerState _parseBoolParam(Uri.base.queryParameters['drySend']) && !AuthProxyService.isProdEnv; _redirectUrl = widget.redirectUrl; + _passwordFocusNode.addListener(_handlePasswordFocusChange); + HardwareKeyboard.instance.addHandler(_handleHardwareKeyEvent); WidgetsBinding.instance.addPostFrameCallback((_) async { final uri = Uri.base; @@ -154,6 +159,40 @@ class _LoginScreenState extends ConsumerState }); } + void _handlePasswordFocusChange() { + if (!mounted) { + return; + } + if (_passwordFocusNode.hasFocus) { + _syncPasswordCapsLockState(); + return; + } + if (_isPasswordCapsLockOn) { + setState(() { + _isPasswordCapsLockOn = false; + }); + } + } + + bool _handleHardwareKeyEvent(KeyEvent event) { + if (_passwordFocusNode.hasFocus) { + _syncPasswordCapsLockState(); + } + return false; + } + + void _syncPasswordCapsLockState() { + final isEnabled = HardwareKeyboard.instance.lockModesEnabled.contains( + KeyboardLockMode.capsLock, + ); + if (!mounted || isEnabled == _isPasswordCapsLockOn) { + return; + } + setState(() { + _isPasswordCapsLockOn = isEnabled; + }); + } + Future _tryCookieSession({bool silent = true}) async { final loginChallenge = _loginChallenge; final token = AuthTokenStore.getToken(); @@ -936,6 +975,10 @@ class _LoginScreenState extends ConsumerState _linkIdController.dispose(); _passwordLoginIdController.dispose(); _passwordController.dispose(); + _passwordFocusNode + ..removeListener(_handlePasswordFocusChange) + ..dispose(); + HardwareKeyboard.instance.removeHandler(_handleHardwareKeyEvent); _shortCodePrefixController.dispose(); _shortCodeDigitsController.dispose(); _linkResendTimer?.cancel(); @@ -1299,6 +1342,24 @@ class _LoginScreenState extends ConsumerState ); } + String _capsLockWarningText(BuildContext context) { + const key = 'msg.userfront.login.password.caps_lock_on'; + final languageCode = Localizations.localeOf(context).languageCode; + if (languageCode == 'ko') { + final translated = tr(key); + if (translated != key) { + return translated; + } + return 'Caps Lock이 켜져 있습니다.'; + } + + final translated = tr(key, fallback: 'Caps Lock is on.'); + if (translated != key) { + return translated; + } + return 'Caps Lock is on.'; + } + @override Widget build(BuildContext context) { if (_verificationOnly && _verificationApproved) { @@ -1410,6 +1471,7 @@ class _LoginScreenState extends ConsumerState key: const ValueKey( 'password_login_password_input', ), + focusNode: _passwordFocusNode, controller: _passwordController, obscureText: true, decoration: InputDecoration( @@ -1423,6 +1485,29 @@ class _LoginScreenState extends ConsumerState ), onSubmitted: (_) => _handlePasswordLogin(), ), + if (_isPasswordCapsLockOn) ...[ + const SizedBox(height: 8), + Row( + children: [ + const Icon( + Icons.keyboard_capslock_rounded, + size: 18, + color: Colors.orange, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + _capsLockWarningText(context), + style: const TextStyle( + color: Colors.orange, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ], + ), + ], const SizedBox(height: 24), FilledButton( key: const ValueKey(