From 467d936c5bd25703bd041225ca882258986d14db Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Salazar?= <73718835+joshrs23@users.noreply.github.com> Date: Wed, 4 Feb 2026 14:10:02 +0900 Subject: [PATCH 1/2] solving problem with login loop and sending code time --- .../auth_repository_impl.dart | 11 ++ .../auth_repository_interface.dart | 3 + .../usecases/sign_in_with_phone_usecase.dart | 4 + .../lib/src/presentation/blocs/auth_bloc.dart | 111 +++++++++++++++++- .../src/presentation/blocs/auth_event.dart | 21 ++++ .../src/presentation/blocs/auth_state.dart | 8 ++ .../pages/phone_verification_page.dart | 101 +++++++++++----- .../phone_verification_page/phone_input.dart | 13 +- .../phone_input/phone_input_form_field.dart | 12 ++ 9 files changed, 247 insertions(+), 37 deletions(-) diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart index 742714fc..10dbffbe 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/data/repositories_impl/auth_repository_impl.dart @@ -18,6 +18,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { final FirebaseAuth firebaseAuth; final ExampleConnector dataConnect; + Completer? _pendingVerification; @override Stream get currentUser => firebaseAuth @@ -39,6 +40,7 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { @override Future signInWithPhone({required String phoneNumber}) async { final Completer completer = Completer(); + _pendingVerification = completer; await firebaseAuth.verifyPhoneNumber( phoneNumber: phoneNumber, @@ -76,6 +78,15 @@ class AuthRepositoryImpl implements AuthRepositoryInterface { return completer.future; } + @override + void cancelPendingPhoneVerification() { + final Completer? completer = _pendingVerification; + if (completer != null && !completer.isCompleted) { + completer.completeError(Exception('Phone verification cancelled.')); + } + _pendingVerification = null; + } + /// Signs out the current user. @override Future signOut() { diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart index b893e705..12e05413 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/repositories/auth_repository_interface.dart @@ -8,6 +8,9 @@ abstract interface class AuthRepositoryInterface { /// Signs in with a phone number and returns a verification ID. Future signInWithPhone({required String phoneNumber}); + /// Cancels any pending phone verification request (if possible). + void cancelPendingPhoneVerification(); + /// Verifies the OTP code and returns the authenticated user. Future verifyOtp({ required String verificationId, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart index 061fd08e..ed2878e4 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/domain/usecases/sign_in_with_phone_usecase.dart @@ -18,4 +18,8 @@ class SignInWithPhoneUseCase Future call(SignInWithPhoneArguments arguments) { return _repository.signInWithPhone(phoneNumber: arguments.phoneNumber); } + + void cancelPending() { + _repository.cancelPendingPhoneVerification(); + } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart index bf605543..b3718543 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_bloc.dart @@ -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 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 implements Disposable { on(_onErrorCleared); on(_onOtpUpdated); on(_onPhoneUpdated); + on(_onResetRequested); + on(_onCooldownTicked); } /// Clears any authentication error from the state. void _onErrorCleared(AuthErrorCleared event, Emitter 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 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 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 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 implements Disposable { AuthSignInRequested event, Emitter 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 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 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 _onOtpSubmitted( AuthOtpSubmitted event, @@ -107,6 +209,7 @@ class AuthBloc extends Bloc implements Disposable { /// Disposes the BLoC resources. @override void dispose() { + _cancelCooldownTimer(); close(); } } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart index f26c339a..51407bb9 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_event.dart @@ -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 get props => [mode]; +} + +/// Event for ticking down the resend cooldown. +class AuthCooldownTicked extends AuthEvent { + final int secondsRemaining; + + const AuthCooldownTicked(this.secondsRemaining); + + @override + List get props => [secondsRemaining]; +} + /// Event for updating the current draft OTP in the state. class AuthOtpUpdated extends AuthEvent { /// The current draft OTP. diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_state.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_state.dart index edcbfe3a..eaa6f1f2 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_state.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/blocs/auth_state.dart @@ -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, ); } diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index 2a1bc849..4310c75e 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -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 createState() => _PhoneVerificationPageState(); +} + +class _PhoneVerificationPageState extends State { + late final AuthBloc _authBloc; + + @override + void initState() { + super.initState(); + _authBloc = Modular.get(); + _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( 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(context).add(AuthSignInRequested(mode: mode)); + BlocProvider.of(context).add( + AuthSignInRequested(mode: widget.mode), + ); } @override Widget build(BuildContext context) { - return BlocProvider( - create: (BuildContext context) => Modular.get(), + return BlocProvider.value( + value: _authBloc, child: Builder( builder: (BuildContext context) { return BlocListener( @@ -104,34 +129,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( + context, + ).add(AuthResetRequested(mode: widget.mode)); + return true; + }, + child: Scaffold( + appBar: UiAppBar( + centerTitle: true, + showBackButton: true, + onLeadingPressed: () { + BlocProvider.of(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, ), ), + ), ), ); }, diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart index 9ad647f3..7eb7b850 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input.dart @@ -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 onSendCode; @override State createState() => _PhoneInputState(); } class _PhoneInputState extends State { + 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(); if (!bloc.isClosed) { bloc.add(AuthPhoneUpdated(value)); @@ -59,7 +68,7 @@ class _PhoneInputState extends State { ), PhoneInputActions( isLoading: widget.state.isLoading, - onSendCode: widget.onSendCode, + onSendCode: () => widget.onSendCode(_currentPhone), ), ], ); diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart index 4fa8104f..dc29e107 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/widgets/phone_verification_page/phone_input/phone_input_form_field.dart @@ -37,6 +37,18 @@ class _PhoneInputFormFieldState extends State { _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(); From 91e14a258e26bd3c1a6740b73076b0a5070a7b4c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jos=C3=A9=20Salazar?= <73718835+joshrs23@users.noreply.github.com> Date: Wed, 4 Feb 2026 15:21:13 +0900 Subject: [PATCH 2/2] adding redirection if user already has a staff --- .../pages/phone_verification_page.dart | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart index 4310c75e..5724021d 100644 --- a/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart +++ b/apps/mobile/packages/features/staff/authentication/lib/src/presentation/pages/phone_verification_page.dart @@ -108,12 +108,19 @@ class _PhoneVerificationPageState extends State { state.mode == AuthMode.signup) { final String message = state.errorMessage ?? ''; if (message.contains('staff profile')) { - Modular.to.pushReplacementNamed( - './phone-verification', - arguments: { - 'mode': AuthMode.login.name, - }, + final ScaffoldMessengerState messenger = + ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 5), + ), ); + Future.delayed(const Duration(seconds: 5), () { + if (!mounted) return; + Modular.to.navigate('/'); + }); } else if (message.contains('not authorized')) { Modular.to.pop(); }