Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 121 additions & 29 deletions lib/screens/auth/verify_otp_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,15 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
(index) => TextEditingController(),
);
final List<FocusNode> _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<FocusNode> _backspaceListenerNodes = List.generate(
6,
(index) => FocusNode(skipTraversal: true, canRequestFocus: false),
);
Timer? _resendTimer;
bool _showtimertext = false;
bool _timerStarted = false;
Expand All @@ -50,6 +59,9 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
for (var node in _focusNodes) {
node.dispose();
}
for (var node in _backspaceListenerNodes) {
node.dispose();
}
super.dispose();
}

Expand All @@ -63,6 +75,60 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
});
}
}

// 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();
}
} 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 (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();
_controllers[index - 1].clear();
_checkotpcomplete();
}
void _showErrorSnackBar(String message) {
ScaffoldMessenger.of(context).clearSnackBars();
ScaffoldMessenger.of(context).showSnackBar(
Expand Down Expand Up @@ -118,6 +184,8 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
userData: widget.userData,
);

if (!mounted) return;

if (result['success']) {
// Handle successful verification based on verify type
if (widget.verifyType == 'signup_create') {
Expand Down Expand Up @@ -152,6 +220,7 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
_showErrorSnackBar(_errorMessage!);
}
} catch (e) {
if (!mounted) return;
setState(() {
String errorMsg = e.toString();

Expand Down Expand Up @@ -201,6 +270,8 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
type: widget.verifyType,
);

if (!mounted) return;

if (result['success']) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
Expand All @@ -225,6 +296,7 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
_showErrorSnackBar(_errorMessage!);
}
} catch (e) {
if (!mounted) return;
setState(() {
String errorMsg = e.toString();

Expand Down Expand Up @@ -334,6 +406,7 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {

@override
Widget build(BuildContext context) {
final resendDisabled = _isLoading || !_canresend;
return AuthScreenWrapper(
title: 'Verify Email',
subtitle: 'Enter the 6-digit code sent to ${widget.email}',
Expand Down Expand Up @@ -366,35 +439,39 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
(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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
_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),
),
),
),
),
Expand Down Expand Up @@ -449,17 +526,32 @@ class _VerifyOTPScreenState extends State<VerifyOTPScreen> {
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,
),
),
),
],
),
const SizedBox(height: 16),
CustomButton(
text: 'Back',
onPressed: _isLoading
? null
: () {
NavigationService().goBack();
},
isOutlined: true,
),
],
);
}
Expand Down