Merge pull request #366 from Oloodi/357-staff-app-users-get-stuck-in-otp-error-loop-when-signing-up-with-existing-account

357 staff app users get stuck in otp error loop when signing up with existing account
This commit is contained in:
José Salazar
2026-02-04 01:27:03 -05:00
committed by GitHub
9 changed files with 259 additions and 42 deletions

View File

@@ -18,6 +18,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
final FirebaseAuth firebaseAuth; final FirebaseAuth firebaseAuth;
final ExampleConnector dataConnect; final ExampleConnector dataConnect;
Completer<String?>? _pendingVerification;
@override @override
Stream<domain.User?> get currentUser => firebaseAuth Stream<domain.User?> get currentUser => firebaseAuth
@@ -39,6 +40,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
@override @override
Future<String?> signInWithPhone({required String phoneNumber}) async { Future<String?> signInWithPhone({required String phoneNumber}) async {
final Completer<String?> completer = Completer<String?>(); final Completer<String?> completer = Completer<String?>();
_pendingVerification = completer;
await firebaseAuth.verifyPhoneNumber( await firebaseAuth.verifyPhoneNumber(
phoneNumber: phoneNumber, phoneNumber: phoneNumber,
@@ -76,6 +78,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
return completer.future; return completer.future;
} }
@override
void cancelPendingPhoneVerification() {
final Completer<String?>? completer = _pendingVerification;
if (completer != null && !completer.isCompleted) {
completer.completeError(Exception('Phone verification cancelled.'));
}
_pendingVerification = null;
}
/// Signs out the current user. /// Signs out the current user.
@override @override
Future<void> signOut() { Future<void> signOut() {

View File

@@ -8,6 +8,9 @@ abstract interface class AuthRepositoryInterface {
/// Signs in with a phone number and returns a verification ID. /// Signs in with a phone number and returns a verification ID.
Future<String?> signInWithPhone({required String phoneNumber}); Future<String?> signInWithPhone({required String phoneNumber});
/// Cancels any pending phone verification request (if possible).
void cancelPendingPhoneVerification();
/// Verifies the OTP code and returns the authenticated user. /// Verifies the OTP code and returns the authenticated user.
Future<User?> verifyOtp({ Future<User?> verifyOtp({
required String verificationId, required String verificationId,

View File

@@ -18,4 +18,8 @@ class SignInWithPhoneUseCase
Future<String?> call(SignInWithPhoneArguments arguments) { Future<String?> call(SignInWithPhoneArguments arguments) {
return _repository.signInWithPhone(phoneNumber: arguments.phoneNumber); return _repository.signInWithPhone(phoneNumber: arguments.phoneNumber);
} }
void cancelPending() {
_repository.cancelPendingPhoneVerification();
}
} }

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter_modular/flutter_modular.dart'; import 'package:flutter_modular/flutter_modular.dart';
import 'package:bloc/bloc.dart'; import 'package:bloc/bloc.dart';
import 'package:krow_domain/krow_domain.dart'; import 'package:krow_domain/krow_domain.dart';
@@ -15,6 +16,11 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
/// The use case for verifying an OTP. /// The use case for verifying an OTP.
final VerifyOtpUseCase _verifyOtpUseCase; final VerifyOtpUseCase _verifyOtpUseCase;
int _requestToken = 0;
DateTime? _lastCodeRequestAt;
DateTime? _cooldownUntil;
static const Duration _resendCooldown = Duration(seconds: 31);
Timer? _cooldownTimer;
/// Creates an [AuthBloc]. /// Creates an [AuthBloc].
AuthBloc({ AuthBloc({
@@ -28,11 +34,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
on<AuthErrorCleared>(_onErrorCleared); on<AuthErrorCleared>(_onErrorCleared);
on<AuthOtpUpdated>(_onOtpUpdated); on<AuthOtpUpdated>(_onOtpUpdated);
on<AuthPhoneUpdated>(_onPhoneUpdated); on<AuthPhoneUpdated>(_onPhoneUpdated);
on<AuthResetRequested>(_onResetRequested);
on<AuthCooldownTicked>(_onCooldownTicked);
} }
/// Clears any authentication error from the state. /// Clears any authentication error from the state.
void _onErrorCleared(AuthErrorCleared event, Emitter<AuthState> emit) { void _onErrorCleared(AuthErrorCleared event, Emitter<AuthState> emit) {
emit(state.copyWith(status: AuthStatus.codeSent, errorMessage: null)); emit(state.copyWith(status: AuthStatus.codeSent, errorMessage: ''));
} }
/// Updates the internal OTP state without triggering a submission. /// Updates the internal OTP state without triggering a submission.
@@ -41,14 +49,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
state.copyWith( state.copyWith(
otp: event.otp, otp: event.otp,
status: AuthStatus.codeSent, status: AuthStatus.codeSent,
errorMessage: null, errorMessage: '',
), ),
); );
} }
/// Updates the internal phone number state without triggering a submission. /// Updates the internal phone number state without triggering a submission.
void _onPhoneUpdated(AuthPhoneUpdated event, Emitter<AuthState> emit) { void _onPhoneUpdated(AuthPhoneUpdated event, Emitter<AuthState> emit) {
emit(state.copyWith(phoneNumber: event.phoneNumber, errorMessage: null)); emit(state.copyWith(phoneNumber: event.phoneNumber, errorMessage: ''));
}
/// Resets the authentication state to initial for a given mode.
void _onResetRequested(AuthResetRequested event, Emitter<AuthState> emit) {
_requestToken++;
_signInUseCase.cancelPending();
_cancelCooldownTimer();
emit(AuthState(status: AuthStatus.initial, mode: event.mode));
} }
/// Handles the sign-in request, initiating the phone authentication process. /// Handles the sign-in request, initiating the phone authentication process.
@@ -56,11 +72,37 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
AuthSignInRequested event, AuthSignInRequested event,
Emitter<AuthState> emit, Emitter<AuthState> emit,
) async { ) async {
final DateTime now = DateTime.now();
if (_lastCodeRequestAt != null) {
final DateTime cooldownUntil =
_cooldownUntil ?? _lastCodeRequestAt!.add(_resendCooldown);
final int remaining = cooldownUntil.difference(now).inSeconds;
if (remaining > 0) {
_startCooldown(remaining);
emit(
state.copyWith(
status: AuthStatus.error,
mode: event.mode,
phoneNumber: event.phoneNumber ?? state.phoneNumber,
errorMessage: 'Please wait ${remaining}s before requesting a new code.',
cooldownSecondsRemaining: remaining,
),
);
return;
}
}
_signInUseCase.cancelPending();
final int token = ++_requestToken;
_lastCodeRequestAt = now;
_cooldownUntil = now.add(_resendCooldown);
_cancelCooldownTimer();
emit( emit(
state.copyWith( state.copyWith(
status: AuthStatus.loading, status: AuthStatus.loading,
mode: event.mode, mode: event.mode,
phoneNumber: event.phoneNumber, phoneNumber: event.phoneNumber,
cooldownSecondsRemaining: 0,
), ),
); );
try { try {
@@ -69,19 +111,79 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
phoneNumber: event.phoneNumber ?? state.phoneNumber, phoneNumber: event.phoneNumber ?? state.phoneNumber,
), ),
); );
if (token != _requestToken) return;
emit( emit(
state.copyWith( state.copyWith(
status: AuthStatus.codeSent, status: AuthStatus.codeSent,
verificationId: verificationId, verificationId: verificationId,
cooldownSecondsRemaining: 0,
), ),
); );
} catch (e) { } catch (e) {
if (token != _requestToken) return;
emit( emit(
state.copyWith(status: AuthStatus.error, errorMessage: e.toString()), state.copyWith(
status: AuthStatus.error,
errorMessage: e.toString(),
cooldownSecondsRemaining: 0,
),
); );
} }
} }
void _onCooldownTicked(
AuthCooldownTicked event,
Emitter<AuthState> emit,
) {
print('Auth cooldown tick: ${event.secondsRemaining}');
if (event.secondsRemaining <= 0) {
print('Auth cooldown finished: clearing message');
_cancelCooldownTimer();
_cooldownUntil = null;
emit(
state.copyWith(
status: AuthStatus.initial,
errorMessage: '',
cooldownSecondsRemaining: 0,
),
);
return;
}
emit(
state.copyWith(
status: AuthStatus.error,
errorMessage:
'Please wait ${event.secondsRemaining}s before requesting a new code.',
cooldownSecondsRemaining: event.secondsRemaining,
),
);
}
void _startCooldown(int secondsRemaining) {
_cancelCooldownTimer();
int remaining = secondsRemaining;
add(AuthCooldownTicked(remaining));
_cooldownTimer = Timer.periodic(const Duration(seconds: 1), (Timer timer) {
remaining -= 1;
print('Auth cooldown timer: remaining=$remaining');
if (remaining <= 0) {
timer.cancel();
_cooldownTimer = null;
print('Auth cooldown timer: reached 0, emitting tick');
add(const AuthCooldownTicked(0));
return;
}
add(AuthCooldownTicked(remaining));
});
}
void _cancelCooldownTimer() {
_cooldownTimer?.cancel();
_cooldownTimer = null;
}
/// Handles OTP submission and verification. /// Handles OTP submission and verification.
Future<void> _onOtpSubmitted( Future<void> _onOtpSubmitted(
AuthOtpSubmitted event, AuthOtpSubmitted event,
@@ -107,6 +209,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
/// Disposes the BLoC resources. /// Disposes the BLoC resources.
@override @override
void dispose() { void dispose() {
_cancelCooldownTimer();
close(); close();
} }
} }

View File

@@ -49,6 +49,27 @@ class AuthOtpSubmitted extends AuthEvent {
/// Event for clearing any authentication error in the state. /// Event for clearing any authentication error in the state.
class AuthErrorCleared extends AuthEvent {} class AuthErrorCleared extends AuthEvent {}
/// Event for resetting the authentication flow back to initial.
class AuthResetRequested extends AuthEvent {
/// The authentication mode (login or signup).
final AuthMode mode;
const AuthResetRequested({required this.mode});
@override
List<Object> get props => <Object>[mode];
}
/// Event for ticking down the resend cooldown.
class AuthCooldownTicked extends AuthEvent {
final int secondsRemaining;
const AuthCooldownTicked(this.secondsRemaining);
@override
List<Object> get props => <Object>[secondsRemaining];
}
/// Event for updating the current draft OTP in the state. /// Event for updating the current draft OTP in the state.
class AuthOtpUpdated extends AuthEvent { class AuthOtpUpdated extends AuthEvent {
/// The current draft OTP. /// The current draft OTP.

View File

@@ -40,6 +40,9 @@ class AuthState extends Equatable {
/// A descriptive message for any error that occurred. /// A descriptive message for any error that occurred.
final String? errorMessage; final String? errorMessage;
/// Cooldown in seconds before requesting a new code.
final int cooldownSecondsRemaining;
/// The authenticated user's data (available when status is [AuthStatus.authenticated]). /// The authenticated user's data (available when status is [AuthStatus.authenticated]).
final User? user; final User? user;
@@ -50,6 +53,7 @@ class AuthState extends Equatable {
this.otp = '', this.otp = '',
this.phoneNumber = '', this.phoneNumber = '',
this.errorMessage, this.errorMessage,
this.cooldownSecondsRemaining = 0,
this.user, this.user,
}); });
@@ -61,6 +65,7 @@ class AuthState extends Equatable {
otp, otp,
phoneNumber, phoneNumber,
errorMessage, errorMessage,
cooldownSecondsRemaining,
user, user,
]; ];
@@ -78,6 +83,7 @@ class AuthState extends Equatable {
String? otp, String? otp,
String? phoneNumber, String? phoneNumber,
String? errorMessage, String? errorMessage,
int? cooldownSecondsRemaining,
User? user, User? user,
}) { }) {
return AuthState( return AuthState(
@@ -87,6 +93,8 @@ class AuthState extends Equatable {
otp: otp ?? this.otp, otp: otp ?? this.otp,
phoneNumber: phoneNumber ?? this.phoneNumber, phoneNumber: phoneNumber ?? this.phoneNumber,
errorMessage: errorMessage ?? this.errorMessage, errorMessage: errorMessage ?? this.errorMessage,
cooldownSecondsRemaining:
cooldownSecondsRemaining ?? this.cooldownSecondsRemaining,
user: user ?? this.user, user: user ?? this.user,
); );
} }

View File

@@ -15,23 +15,46 @@ import '../widgets/phone_verification_page/phone_input.dart';
/// ///
/// This page coordinates the authentication flow by switching between /// This page coordinates the authentication flow by switching between
/// [PhoneInput] and [OtpVerification] based on the current [AuthState]. /// [PhoneInput] and [OtpVerification] based on the current [AuthState].
class PhoneVerificationPage extends StatelessWidget { class PhoneVerificationPage extends StatefulWidget {
/// The authentication mode (login or signup). /// The authentication mode (login or signup).
final AuthMode mode; final AuthMode mode;
/// Creates a [PhoneVerificationPage]. /// Creates a [PhoneVerificationPage].
const PhoneVerificationPage({super.key, required this.mode}); const PhoneVerificationPage({super.key, required this.mode});
@override
State<PhoneVerificationPage> createState() => _PhoneVerificationPageState();
}
class _PhoneVerificationPageState extends State<PhoneVerificationPage> {
late final AuthBloc _authBloc;
@override
void initState() {
super.initState();
_authBloc = Modular.get<AuthBloc>();
_authBloc.add(AuthResetRequested(mode: widget.mode));
}
@override
void dispose() {
_authBloc.add(AuthResetRequested(mode: widget.mode));
super.dispose();
}
/// Handles the request to send a verification code to the provided phone number. /// Handles the request to send a verification code to the provided phone number.
void _onSendCode({ void _onSendCode({
required BuildContext context, required BuildContext context,
required String phoneNumber, required String phoneNumber,
}) { }) {
print('Phone verification input: "$phoneNumber" len=${phoneNumber.length}'); final String normalized = phoneNumber.replaceAll(RegExp(r'\\D'), '');
if (phoneNumber.length == 10) { print('Phone verification input: "$normalized" len=${normalized.length}');
if (normalized.length == 10) {
BlocProvider.of<AuthBloc>( BlocProvider.of<AuthBloc>(
context, context,
).add(AuthSignInRequested(phoneNumber: '+1$phoneNumber', mode: mode)); ).add(
AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode),
);
} else { } else {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
SnackBar( SnackBar(
@@ -55,20 +78,22 @@ class PhoneVerificationPage extends StatelessWidget {
AuthOtpSubmitted( AuthOtpSubmitted(
verificationId: verificationId, verificationId: verificationId,
smsCode: otp, smsCode: otp,
mode: mode, mode: widget.mode,
), ),
); );
} }
/// Handles the request to resend the verification code using the phone number in the state. /// Handles the request to resend the verification code using the phone number in the state.
void _onResend({required BuildContext context}) { void _onResend({required BuildContext context}) {
BlocProvider.of<AuthBloc>(context).add(AuthSignInRequested(mode: mode)); BlocProvider.of<AuthBloc>(context).add(
AuthSignInRequested(mode: widget.mode),
);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BlocProvider<AuthBloc>( return BlocProvider<AuthBloc>.value(
create: (BuildContext context) => Modular.get<AuthBloc>(), value: _authBloc,
child: Builder( child: Builder(
builder: (BuildContext context) { builder: (BuildContext context) {
return BlocListener<AuthBloc, AuthState>( return BlocListener<AuthBloc, AuthState>(
@@ -83,12 +108,19 @@ class PhoneVerificationPage extends StatelessWidget {
state.mode == AuthMode.signup) { state.mode == AuthMode.signup) {
final String message = state.errorMessage ?? ''; final String message = state.errorMessage ?? '';
if (message.contains('staff profile')) { if (message.contains('staff profile')) {
Modular.to.pushReplacementNamed( final ScaffoldMessengerState messenger =
'./phone-verification', ScaffoldMessenger.of(context);
arguments: <String, String>{ messenger.hideCurrentSnackBar();
'mode': AuthMode.login.name, messenger.showSnackBar(
}, SnackBar(
content: Text(message),
duration: const Duration(seconds: 5),
),
); );
Future<void>.delayed(const Duration(seconds: 5), () {
if (!mounted) return;
Modular.to.navigate('/');
});
} else if (message.contains('not authorized')) { } else if (message.contains('not authorized')) {
Modular.to.pop(); Modular.to.pop();
} }
@@ -104,34 +136,48 @@ class PhoneVerificationPage extends StatelessWidget {
(state.status == AuthStatus.loading && (state.status == AuthStatus.loading &&
state.verificationId != null); state.verificationId != null);
return Scaffold( return WillPopScope(
appBar: const UiAppBar( onWillPop: () async {
centerTitle: true, BlocProvider.of<AuthBloc>(
showBackButton: true, context,
), ).add(AuthResetRequested(mode: widget.mode));
body: SafeArea( return true;
child: isOtpStep },
? OtpVerification( child: Scaffold(
state: state, appBar: UiAppBar(
onOtpSubmitted: (String otp) => _onOtpSubmitted( centerTitle: true,
context: context, showBackButton: true,
otp: otp, onLeadingPressed: () {
verificationId: state.verificationId ?? '', BlocProvider.of<AuthBloc>(context).add(
), AuthResetRequested(mode: widget.mode),
onResend: () => _onResend(context: context), );
onContinue: () => _onOtpSubmitted( Navigator.of(context).pop();
context: context, },
otp: state.otp, ),
verificationId: state.verificationId ?? '', body: SafeArea(
), child: isOtpStep
) ? OtpVerification(
state: state,
onOtpSubmitted: (String otp) => _onOtpSubmitted(
context: context,
otp: otp,
verificationId: state.verificationId ?? '',
),
onResend: () => _onResend(context: context),
onContinue: () => _onOtpSubmitted(
context: context,
otp: state.otp,
verificationId: state.verificationId ?? '',
),
)
: PhoneInput( : PhoneInput(
state: state, state: state,
onSendCode: () => _onSendCode( onSendCode: (String phoneNumber) => _onSendCode(
context: context, context: context,
phoneNumber: state.phoneNumber, phoneNumber: phoneNumber,
), ),
), ),
),
), ),
); );
}, },

View File

@@ -17,16 +17,25 @@ class PhoneInput extends StatefulWidget {
final AuthState state; final AuthState state;
/// Callback for when the "Send Code" action is triggered. /// Callback for when the "Send Code" action is triggered.
final VoidCallback onSendCode; final ValueChanged<String> onSendCode;
@override @override
State<PhoneInput> createState() => _PhoneInputState(); State<PhoneInput> createState() => _PhoneInputState();
} }
class _PhoneInputState extends State<PhoneInput> { class _PhoneInputState extends State<PhoneInput> {
String _currentPhone = '';
@override
void initState() {
super.initState();
_currentPhone = widget.state.phoneNumber;
}
void _handlePhoneChanged(String value) { void _handlePhoneChanged(String value) {
if (!mounted) return; if (!mounted) return;
_currentPhone = value;
final AuthBloc bloc = context.read<AuthBloc>(); final AuthBloc bloc = context.read<AuthBloc>();
if (!bloc.isClosed) { if (!bloc.isClosed) {
bloc.add(AuthPhoneUpdated(value)); bloc.add(AuthPhoneUpdated(value));
@@ -59,7 +68,7 @@ class _PhoneInputState extends State<PhoneInput> {
), ),
PhoneInputActions( PhoneInputActions(
isLoading: widget.state.isLoading, isLoading: widget.state.isLoading,
onSendCode: widget.onSendCode, onSendCode: () => widget.onSendCode(_currentPhone),
), ),
], ],
); );

View File

@@ -37,6 +37,18 @@ class _PhoneInputFormFieldState extends State<PhoneInputFormField> {
_controller = TextEditingController(text: widget.initialValue); _controller = TextEditingController(text: widget.initialValue);
} }
@override
void didUpdateWidget(PhoneInputFormField oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.initialValue != oldWidget.initialValue &&
_controller.text != widget.initialValue) {
_controller.text = widget.initialValue;
_controller.selection = TextSelection.fromPosition(
TextPosition(offset: _controller.text.length),
);
}
}
@override @override
void dispose() { void dispose() {
_controller.dispose(); _controller.dispose();