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:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,4 +18,8 @@ class SignInWithPhoneUseCase
|
||||
Future<String?> call(SignInWithPhoneArguments arguments) {
|
||||
return _repository.signInWithPhone(phoneNumber: arguments.phoneNumber);
|
||||
}
|
||||
|
||||
void cancelPending() {
|
||||
_repository.cancelPendingPhoneVerification();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -39,6 +39,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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,34 +136,48 @@ class PhoneVerificationPage extends StatelessWidget {
|
||||
(state.status == AuthStatus.loading &&
|
||||
state.verificationId != null);
|
||||
|
||||
return Scaffold(
|
||||
appBar: const UiAppBar(
|
||||
centerTitle: true,
|
||||
showBackButton: true,
|
||||
),
|
||||
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 ?? '',
|
||||
),
|
||||
)
|
||||
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
|
||||
? 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(
|
||||
state: state,
|
||||
onSendCode: () => _onSendCode(
|
||||
onSendCode: (String phoneNumber) => _onSendCode(
|
||||
context: context,
|
||||
phoneNumber: state.phoneNumber,
|
||||
phoneNumber: phoneNumber,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user