From 86a1edeb6d495f59378ecca8c6dc628fa081820e Mon Sep 17 00:00:00 2001 From: g-k-s-03 Date: Sat, 12 Sep 2026 01:33:24 +0530 Subject: [PATCH 1/5] feat(auth): add back button to OTP verification screen VerifyOTPScreen had no way to leave the screen -- no back arrow (the shared AuthScreenWrapper has no AppBar) and no in-body cancel/back action, unlike ForgotPasswordScreen and SetNewPasswordScreen which each already have their own equivalent button. Add a "Back" CustomButton (isOutlined: true) below the Resend row, matching those two screens' existing button style. Uses NavigationService().goBack() rather than a literal "Back to Login" label/redirect, since this screen is reachable from both SignupScreen and ForgotPasswordScreen -- goBack() correctly pops to whichever one pushed it. --- lib/screens/auth/verify_otp_screen.dart | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/lib/screens/auth/verify_otp_screen.dart b/lib/screens/auth/verify_otp_screen.dart index 400ee78..bd022af 100644 --- a/lib/screens/auth/verify_otp_screen.dart +++ b/lib/screens/auth/verify_otp_screen.dart @@ -460,6 +460,14 @@ class _VerifyOTPScreenState extends State { ), ], ), + const SizedBox(height: 16), + CustomButton( + text: 'Back', + onPressed: () { + NavigationService().goBack(); + }, + isOutlined: true, + ), ], ); } From 4f86457e408438302c21def2160c88ed966d9d0e Mon Sep 17 00:00:00 2001 From: g-k-s-03 Date: Sat, 12 Sep 2026 01:33:47 +0530 Subject: [PATCH 2/5] fix(auth): disable Resend button during cooldown period The Resend TextButton's onPressed was gated only by _isLoading, not the existing _canresend cooldown flag, so it remained fully tappable (just silently no-opping via _resendCode's early return) for the whole 60-second cooldown -- no visible feedback that it was inert. Gate onPressed on (_isLoading || !_canresend). Also fix the button's disabled state actually looking disabled: the "Resend" Text had a hardcoded green color that would have stayed fully green regardless of onPressed being null, since an explicitly-styled child Text isn't repainted by TextButton's automatic disabled-state dimming. Now dims to a muted onSurfaceVariant color while disabled. --- lib/screens/auth/verify_otp_screen.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/screens/auth/verify_otp_screen.dart b/lib/screens/auth/verify_otp_screen.dart index bd022af..38cd91d 100644 --- a/lib/screens/auth/verify_otp_screen.dart +++ b/lib/screens/auth/verify_otp_screen.dart @@ -334,6 +334,7 @@ class _VerifyOTPScreenState extends State { @override Widget build(BuildContext context) { + final resendDisabled = _isLoading || !_canresend; return AuthScreenWrapper( title: 'Verify Email', subtitle: 'Enter the 6-digit code sent to ${widget.email}', @@ -449,11 +450,16 @@ class _VerifyOTPScreenState extends State { color: Theme.of(context).colorScheme.onSurfaceVariant), ), TextButton( - onPressed: _isLoading ? null : _resendCode, + onPressed: resendDisabled ? null : _resendCode, child: Text( 'Resend', style: TextStyle( - color: Colors.green.shade400, + color: resendDisabled + ? Theme.of(context) + .colorScheme + .onSurfaceVariant + .withOpacity(0.5) + : Colors.green.shade400, fontWeight: FontWeight.w600, ), ), From ad2d7b19d34b862dc3cdba8215b99f2e7b6dad97 Mon Sep 17 00:00:00 2001 From: g-k-s-03 Date: Sat, 12 Sep 2026 01:34:16 +0530 Subject: [PATCH 3/5] feat(auth): add backspace navigation and paste support to OTP input Each of the 6 OTP boxes only auto-advanced focus forward on input; there was no way to move back to a previous box via backspace, and pasting a full 6-digit code did nothing useful (maxLength: 1 silently truncated a paste to its first character before onChanged ever saw it). Backspace: wrap each box in a KeyboardListener (using a dedicated, non-focus-stealing FocusNode per box) that catches a backspace keydown while the box is already empty -- onChanged never fires for that case, since no text actually changes -- and moves focus back to (clearing) the previous box. Paste: set maxLengthEnforcement: MaxLengthEnforcement.none so a full pasted string reaches onChanged instead of being truncated, add FilteringTextInputFormatter.digitsOnly so non-digit paste content is stripped, and distribute a multi-character value across the current and subsequent boxes, landing focus on the last one filled. --- lib/screens/auth/verify_otp_screen.dart | 111 ++++++++++++++++++------ 1 file changed, 84 insertions(+), 27 deletions(-) diff --git a/lib/screens/auth/verify_otp_screen.dart b/lib/screens/auth/verify_otp_screen.dart index 38cd91d..091d9b6 100644 --- a/lib/screens/auth/verify_otp_screen.dart +++ b/lib/screens/auth/verify_otp_screen.dart @@ -30,6 +30,15 @@ class _VerifyOTPScreenState extends State { (index) => TextEditingController(), ); final List _focusNodes = List.generate(6, (index) => FocusNode()); + // One extra FocusNode per box, purely to intercept backspace key events on + // an already-empty box (onChanged never fires for that -- there's no text + // change) via ancestor bubbling in the focus tree. Never requests focus + // itself (canRequestFocus: false), so it doesn't interfere with the real + // per-box FocusNodes above. + final List _backspaceListenerNodes = List.generate( + 6, + (index) => FocusNode(skipTraversal: true, canRequestFocus: false), + ); Timer? _resendTimer; bool _showtimertext = false; bool _timerStarted = false; @@ -50,6 +59,9 @@ class _VerifyOTPScreenState extends State { for (var node in _focusNodes) { node.dispose(); } + for (var node in _backspaceListenerNodes) { + node.dispose(); + } super.dispose(); } @@ -63,6 +75,47 @@ class _VerifyOTPScreenState extends State { }); } } + + // Handles both normal single-digit typing and a multi-digit paste. Paste + // reaches here as a single onChanged call with the full pasted string, + // since maxLengthEnforcement is set to none on the field below (default + // maxLength enforcement would silently truncate a paste to 1 char before + // onChanged ever sees it). + void _handleOtpChanged(int index, String value) { + if (value.length > 1) { + var target = index; + for (final digit in value.split('')) { + if (target > 5) break; + _controllers[target].text = digit; + target++; + } + final lastFilled = target > 5 ? 5 : target; + _focusNodes[lastFilled].requestFocus(); + _controllers[lastFilled].selection = TextSelection.collapsed( + offset: _controllers[lastFilled].text.length, + ); + _checkotpcomplete(); + return; + } + + if (value.isNotEmpty) { + if (index < 5) { + _focusNodes[index + 1].requestFocus(); + } else { + _focusNodes[index].unfocus(); + } + } + _checkotpcomplete(); + } + + // Backspace on an already-empty box: move focus to (and clear) the + // previous box, matching standard OTP-input UX. + void _handleBackspaceOnEmpty(int index) { + if (index == 0) return; + _focusNodes[index - 1].requestFocus(); + _controllers[index - 1].clear(); + _checkotpcomplete(); + } void _showErrorSnackBar(String message) { ScaffoldMessenger.of(context).clearSnackBars(); ScaffoldMessenger.of(context).showSnackBar( @@ -367,35 +420,39 @@ class _VerifyOTPScreenState extends State { (index) => SizedBox( width: 50, height: 60, - child: TextField( - controller: _controllers[index], - focusNode: _focusNodes[index], - keyboardType: TextInputType.number, - maxLength: 1, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onSurface, - ), - decoration: InputDecoration( - counterText: '', - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(15), - ), - ), - onChanged: (value) { - if (value.isNotEmpty) { - if (index < 5) { - _focusNodes[index + 1].requestFocus(); - } else { - _focusNodes[index].unfocus(); - // _handleVerification(); - } + child: KeyboardListener( + focusNode: _backspaceListenerNodes[index], + onKeyEvent: (event) { + if (event is KeyDownEvent && + event.logicalKey == LogicalKeyboardKey.backspace && + _controllers[index].text.isEmpty) { + _handleBackspaceOnEmpty(index); } - _checkotpcomplete(); }, + child: TextField( + controller: _controllers[index], + focusNode: _focusNodes[index], + keyboardType: TextInputType.number, + maxLength: 1, + // Allow a full pasted string to reach onChanged instead of + // being silently truncated to 1 char before it gets there. + maxLengthEnforcement: MaxLengthEnforcement.none, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onSurface, + ), + decoration: InputDecoration( + counterText: '', + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(15), + ), + ), + onChanged: (value) => _handleOtpChanged(index, value), + ), ), ), ), From d168fb48fe2e2417ba7beb06b69c60a7a1d3cdb4 Mon Sep 17 00:00:00 2001 From: g-k-s-03 Date: Sat, 12 Sep 2026 02:54:07 +0530 Subject: [PATCH 4/5] fix(auth): use mobile-compatible backspace detection for OTP input The existing backspace-to-previous-box handling relied entirely on KeyboardListener catching a raw backspace KeyEvent. That only reliably fires for hardware keyboards -- many mobile on-screen keyboards never emit a KeyEvent at all for backspace on an already-empty field, so the feature silently did nothing on a lot of Android/iOS devices despite working on web/desktop. _handleOtpChanged now treats onChanged firing with an empty value as the primary, cross-platform-reliable trigger to step focus back to the previous box: that callback only ever fires from a genuine text-change event, which every platform's text-input pipeline is guaranteed to report, including on-screen keyboards. This covers the common flow of backspacing digit-by-digit through a filled code. The existing KeyboardListener/_handleBackspaceOnEmpty path is kept as a supplementary hardware-keyboard-only path for the narrower case of pressing backspace again on a box that was already empty -- it's unchanged in behavior and doesn't conflict, since the two paths fire on different triggers (a real text change vs. a raw key event with no text change). --- lib/screens/auth/verify_otp_screen.dart | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/lib/screens/auth/verify_otp_screen.dart b/lib/screens/auth/verify_otp_screen.dart index 091d9b6..cc9feef 100644 --- a/lib/screens/auth/verify_otp_screen.dart +++ b/lib/screens/auth/verify_otp_screen.dart @@ -104,12 +104,25 @@ class _VerifyOTPScreenState extends State { } else { _focusNodes[index].unfocus(); } + } else if (index > 0) { + // This box's own digit was just deleted -- onChanged firing with an + // empty value is a genuine text-change event, which every platform's + // text input (including mobile on-screen keyboards) is guaranteed to + // report, unlike a raw backspace KeyEvent on an ALREADY-empty field + // (handled below via KeyboardListener), which many mobile IMEs never + // emit at all. This is therefore the primary, cross-platform-reliable + // path for "backspace walks focus back through the code"; the + // KeyboardListener below only covers the narrower/rarer case of + // pressing backspace again on a box that was already empty, which + // still works via a real hardware keyboard. + _focusNodes[index - 1].requestFocus(); } _checkotpcomplete(); } - // Backspace on an already-empty box: move focus to (and clear) the - // previous box, matching standard OTP-input UX. + // Backspace on an already-empty box (hardware keyboard only -- see the + // comment in _handleOtpChanged for why mobile IME backspace can't rely on + // this path): move focus to (and clear) the previous box. void _handleBackspaceOnEmpty(int index) { if (index == 0) return; _focusNodes[index - 1].requestFocus(); From bedc3d25f7bd257b9df47309f37126b704025db6 Mon Sep 17 00:00:00 2001 From: g-k-s-03 Date: Sat, 12 Sep 2026 02:54:26 +0530 Subject: [PATCH 5/5] fix(auth): guard against disposed-widget access and disable Back during pending requests The Back button remained enabled while a verify/resend request was in flight (_isLoading == true), so a user could navigate away mid- request; the async continuation would then call setState or touch context after this screen was already disposed. Add `if (!mounted) return;` immediately after both await calls in this file (_handleVerification's verifyOTP call, _resendCode's resendVerificationEmail call) and at the top of both their catch blocks, before any subsequent setState/context/Navigator usage. The existing finally blocks' own `if (mounted)` guards are untouched. Also disable the Back button while _isLoading is true, matching how the Resend button is already disabled during loading -- this is a UX improvement on top of the mounted-check safety net, not a replacement for it, so a user can no longer even attempt to navigate away mid-request in the first place. --- lib/screens/auth/verify_otp_screen.dart | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/lib/screens/auth/verify_otp_screen.dart b/lib/screens/auth/verify_otp_screen.dart index cc9feef..a1de99e 100644 --- a/lib/screens/auth/verify_otp_screen.dart +++ b/lib/screens/auth/verify_otp_screen.dart @@ -184,6 +184,8 @@ class _VerifyOTPScreenState extends State { userData: widget.userData, ); + if (!mounted) return; + if (result['success']) { // Handle successful verification based on verify type if (widget.verifyType == 'signup_create') { @@ -218,6 +220,7 @@ class _VerifyOTPScreenState extends State { _showErrorSnackBar(_errorMessage!); } } catch (e) { + if (!mounted) return; setState(() { String errorMsg = e.toString(); @@ -267,6 +270,8 @@ class _VerifyOTPScreenState extends State { type: widget.verifyType, ); + if (!mounted) return; + if (result['success']) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -291,6 +296,7 @@ class _VerifyOTPScreenState extends State { _showErrorSnackBar(_errorMessage!); } } catch (e) { + if (!mounted) return; setState(() { String errorMsg = e.toString(); @@ -539,9 +545,11 @@ class _VerifyOTPScreenState extends State { const SizedBox(height: 16), CustomButton( text: 'Back', - onPressed: () { - NavigationService().goBack(); - }, + onPressed: _isLoading + ? null + : () { + NavigationService().goBack(); + }, isOutlined: true, ), ],