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 ExampleConnector dataConnect;
Completer<String?>? _pendingVerification;
@override
Stream<domain.User?> get currentUser => firebaseAuth
@@ -39,6 +40,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
@override
Future<String?> signInWithPhone({required String phoneNumber}) async {
final Completer<String?> completer = Completer<String?>();
_pendingVerification = completer;
await firebaseAuth.verifyPhoneNumber(
phoneNumber: phoneNumber,
@@ -76,6 +78,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface {
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.
@override
Future<void> signOut() {

View File

@@ -8,6 +8,9 @@ abstract interface class AuthRepositoryInterface {
/// Signs in with a phone number and returns a verification ID.
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.
Future<User?> verifyOtp({
required String verificationId,

View File

@@ -18,4 +18,8 @@ class SignInWithPhoneUseCase
Future<String?> call(SignInWithPhoneArguments arguments) {
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:bloc/bloc.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.
final VerifyOtpUseCase _verifyOtpUseCase;
int _requestToken = 0;
DateTime? _lastCodeRequestAt;
DateTime? _cooldownUntil;
static const Duration _resendCooldown = Duration(seconds: 31);
Timer? _cooldownTimer;
/// Creates an [AuthBloc].
AuthBloc({
@@ -28,11 +34,13 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
on<AuthErrorCleared>(_onErrorCleared);
on<AuthOtpUpdated>(_onOtpUpdated);
on<AuthPhoneUpdated>(_onPhoneUpdated);
on<AuthResetRequested>(_onResetRequested);
on<AuthCooldownTicked>(_onCooldownTicked);
}
/// Clears any authentication error from the state.
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.
@@ -41,14 +49,22 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
state.copyWith(
otp: event.otp,
status: AuthStatus.codeSent,
errorMessage: null,
errorMessage: '',
),
);
}
/// Updates the internal phone number state without triggering a submission.
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.
@@ -56,11 +72,37 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
AuthSignInRequested event,
Emitter<AuthState> emit,
) 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(
state.copyWith(
status: AuthStatus.loading,
mode: event.mode,
phoneNumber: event.phoneNumber,
cooldownSecondsRemaining: 0,
),
);
try {
@@ -69,19 +111,79 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
phoneNumber: event.phoneNumber ?? state.phoneNumber,
),
);
if (token != _requestToken) return;
emit(
state.copyWith(
status: AuthStatus.codeSent,
verificationId: verificationId,
cooldownSecondsRemaining: 0,
),
);
} catch (e) {
if (token != _requestToken) return;
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.
Future<void> _onOtpSubmitted(
AuthOtpSubmitted event,
@@ -107,6 +209,7 @@ class AuthBloc extends Bloc<AuthEvent, AuthState> implements Disposable {
/// Disposes the BLoC resources.
@override
void dispose() {
_cancelCooldownTimer();
close();
}
}

View File

@@ -49,6 +49,27 @@ class AuthOtpSubmitted extends AuthEvent {
/// Event for clearing any authentication error in the state.
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.
class AuthOtpUpdated extends AuthEvent {
/// The current draft OTP.

View File

@@ -40,6 +40,9 @@ class AuthState extends Equatable {
/// A descriptive message for any error that occurred.
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]).
final User? user;
@@ -50,6 +53,7 @@ class AuthState extends Equatable {
this.otp = '',
this.phoneNumber = '',
this.errorMessage,
this.cooldownSecondsRemaining = 0,
this.user,
});
@@ -61,6 +65,7 @@ class AuthState extends Equatable {
otp,
phoneNumber,
errorMessage,
cooldownSecondsRemaining,
user,
];
@@ -78,6 +83,7 @@ class AuthState extends Equatable {
String? otp,
String? phoneNumber,
String? errorMessage,
int? cooldownSecondsRemaining,
User? user,
}) {
return AuthState(
@@ -87,6 +93,8 @@ class AuthState extends Equatable {
otp: otp ?? this.otp,
phoneNumber: phoneNumber ?? this.phoneNumber,
errorMessage: errorMessage ?? this.errorMessage,
cooldownSecondsRemaining:
cooldownSecondsRemaining ?? this.cooldownSecondsRemaining,
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
/// [PhoneInput] and [OtpVerification] based on the current [AuthState].
class PhoneVerificationPage extends StatelessWidget {
class PhoneVerificationPage extends StatefulWidget {
/// The authentication mode (login or signup).
final AuthMode mode;
/// Creates a [PhoneVerificationPage].
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.
void _onSendCode({
required BuildContext context,
required String phoneNumber,
}) {
print('Phone verification input: "$phoneNumber" len=${phoneNumber.length}');
if (phoneNumber.length == 10) {
final String normalized = phoneNumber.replaceAll(RegExp(r'\\D'), '');
print('Phone verification input: "$normalized" len=${normalized.length}');
if (normalized.length == 10) {
BlocProvider.of<AuthBloc>(
context,
).add(AuthSignInRequested(phoneNumber: '+1$phoneNumber', mode: mode));
).add(
AuthSignInRequested(phoneNumber: '+1$normalized', mode: widget.mode),
);
} else {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
@@ -55,20 +78,22 @@ class PhoneVerificationPage extends StatelessWidget {
AuthOtpSubmitted(
verificationId: verificationId,
smsCode: otp,
mode: mode,
mode: widget.mode,
),
);
}
/// Handles the request to resend the verification code using the phone number in the state.
void _onResend({required BuildContext context}) {
BlocProvider.of<AuthBloc>(context).add(AuthSignInRequested(mode: mode));
BlocProvider.of<AuthBloc>(context).add(
AuthSignInRequested(mode: widget.mode),
);
}
@override
Widget build(BuildContext context) {
return BlocProvider<AuthBloc>(
create: (BuildContext context) => Modular.get<AuthBloc>(),
return BlocProvider<AuthBloc>.value(
value: _authBloc,
child: Builder(
builder: (BuildContext context) {
return BlocListener<AuthBloc, AuthState>(
@@ -83,12 +108,19 @@ class PhoneVerificationPage extends StatelessWidget {
state.mode == AuthMode.signup) {
final String message = state.errorMessage ?? '';
if (message.contains('staff profile')) {
Modular.to.pushReplacementNamed(
'./phone-verification',
arguments: <String, String>{
'mode': AuthMode.login.name,
},
final ScaffoldMessengerState messenger =
ScaffoldMessenger.of(context);
messenger.hideCurrentSnackBar();
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')) {
Modular.to.pop();
}
@@ -104,10 +136,23 @@ class PhoneVerificationPage extends StatelessWidget {
(state.status == AuthStatus.loading &&
state.verificationId != null);
return Scaffold(
appBar: const UiAppBar(
return WillPopScope(
onWillPop: () async {
BlocProvider.of<AuthBloc>(
context,
).add(AuthResetRequested(mode: widget.mode));
return true;
},
child: Scaffold(
appBar: UiAppBar(
centerTitle: true,
showBackButton: true,
onLeadingPressed: () {
BlocProvider.of<AuthBloc>(context).add(
AuthResetRequested(mode: widget.mode),
);
Navigator.of(context).pop();
},
),
body: SafeArea(
child: isOtpStep
@@ -127,9 +172,10 @@ class PhoneVerificationPage extends StatelessWidget {
)
: PhoneInput(
state: state,
onSendCode: () => _onSendCode(
onSendCode: (String phoneNumber) => _onSendCode(
context: context,
phoneNumber: state.phoneNumber,
phoneNumber: phoneNumber,
),
),
),
),

View File

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