diff --git a/BOILERPLATE/COUNTER b/BOILERPLATE/COUNTER index a3be6c6..e72e2db 100644 --- a/BOILERPLATE/COUNTER +++ b/BOILERPLATE/COUNTER @@ -1,2 +1,2 @@ # Increment this counter to push your code again -1 \ No newline at end of file +2 \ No newline at end of file diff --git a/apps/app_core/lib/app/config/api_endpoints.dart b/apps/app_core/lib/app/config/api_endpoints.dart index c6fa26c..65305fb 100644 --- a/apps/app_core/lib/app/config/api_endpoints.dart +++ b/apps/app_core/lib/app/config/api_endpoints.dart @@ -1,6 +1,8 @@ class ApiEndpoints { static const login = '/api/v1/login'; static const signup = '/api/register'; + static const forgotPassword = '/api/v1/forgot-password'; + static const verifyOTP = '/api/v1/verify-otp'; static const profile = '/api/users'; static const logout = '/api/users'; static const socialLogin = '/auth/socialLogin/'; diff --git a/apps/app_core/lib/app/routes/app_router.dart b/apps/app_core/lib/app/routes/app_router.dart index 5a4a0b2..98b487e 100644 --- a/apps/app_core/lib/app/routes/app_router.dart +++ b/apps/app_core/lib/app/routes/app_router.dart @@ -4,11 +4,13 @@ import 'package:app_core/modules/auth/sign_in/screens/sign_in_screen.dart'; import 'package:app_core/modules/auth/sign_up/screens/sign_up_screen.dart'; import 'package:app_core/modules/bottom_navigation_bar.dart'; import 'package:app_core/modules/change_password/screen/change_password_screen.dart'; +import 'package:app_core/modules/forgot_password/screens/forgot_password_screen.dart'; import 'package:app_core/modules/home/screen/home_screen.dart'; import 'package:app_core/modules/profile/screen/edit_profile_screen.dart'; import 'package:app_core/modules/profile/screen/profile_screen.dart'; import 'package:app_core/modules/splash/splash_screen.dart'; import 'package:app_core/modules/subscription/screen/subscription_screen.dart'; +import 'package:app_core/modules/verify_otp/screens/verify_otp_screen.dart'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/cupertino.dart'; @@ -22,6 +24,8 @@ class AppRouter extends RootStackRouter { AutoRoute(page: SubscriptionRoute.page), AutoRoute(initial: true, page: SplashRoute.page, path: '/'), AutoRoute(page: SignInRoute.page), + AutoRoute(page: ForgotPasswordRoute.page), + AutoRoute(page: VerifyOTPRoute.page), AutoRoute( page: BottomNavigationBarRoute.page, guards: [AuthGuard()], @@ -32,11 +36,7 @@ class AppRouter extends RootStackRouter { path: 'account', children: [ AutoRoute(page: ProfileRoute.page), - AutoRoute( - page: ChangePasswordRoute.page, - path: 'change-password', - meta: const {'hideNavBar': true}, - ), + AutoRoute(page: ChangePasswordRoute.page, path: 'change-password', meta: const {'hideNavBar': true}), ], ), ], diff --git a/apps/app_core/lib/modules/auth/model/auth_request_model.dart b/apps/app_core/lib/modules/auth/model/auth_request_model.dart index 5044351..c82895b 100644 --- a/apps/app_core/lib/modules/auth/model/auth_request_model.dart +++ b/apps/app_core/lib/modules/auth/model/auth_request_model.dart @@ -10,6 +10,10 @@ class AuthRequestModel { this.oneSignalPlayerId, }); + AuthRequestModel.verifyOTP({required this.email, required this.token}); + + AuthRequestModel.forgotPassword({required this.email}); + String? email; String? name; String? password; @@ -18,6 +22,7 @@ class AuthRequestModel { String? providerId; String? providerToken; String? oneSignalPlayerId; + String? token; Map toMap() { final map = {}; @@ -28,6 +33,13 @@ class AuthRequestModel { return map; } + Map toVerifyOTPMap() { + final map = {}; + map['email'] = email; + map['token'] = token; + return map; + } + Map toSocialSignInMap() { final map = {}; map['name'] = name; @@ -40,4 +52,10 @@ class AuthRequestModel { map['oneSignalPlayerId'] = oneSignalPlayerId; return map; } + + Map toForgotPasswordMap() { + final map = {}; + map['email'] = email; + return map; + } } diff --git a/apps/app_core/lib/modules/auth/repository/auth_repository.dart b/apps/app_core/lib/modules/auth/repository/auth_repository.dart index f4b792d..26ba9ef 100644 --- a/apps/app_core/lib/modules/auth/repository/auth_repository.dart +++ b/apps/app_core/lib/modules/auth/repository/auth_repository.dart @@ -19,9 +19,11 @@ abstract interface class IAuthRepository { TaskEither logout(); - TaskEither socialLogin({ - required AuthRequestModel requestModel, - }); + TaskEither forgotPassword(AuthRequestModel authRequestModel); + + TaskEither socialLogin({required AuthRequestModel requestModel}); + + TaskEither verifyOTP(AuthRequestModel authRequestModel); } // ignore: comment_references @@ -31,48 +33,28 @@ class AuthRepository implements IAuthRepository { const AuthRepository(); @override - TaskEither login( - AuthRequestModel authRequestModel, - ) => makeLoginRequest(authRequestModel) + TaskEither login(AuthRequestModel authRequestModel) => makeLoginRequest(authRequestModel) .chainEither(RepositoryUtils.checkStatusCode) .chainEither( (response) => RepositoryUtils.mapToModel(() { - return AuthResponseModel.fromMap( - response.data as Map, - ); + return AuthResponseModel.fromMap(response.data as Map); }), ) .flatMap(saveUserToLocal); - TaskEither makeLoginRequest( - AuthRequestModel authRequestModel, - ) => userApiClient.request( + TaskEither makeLoginRequest(AuthRequestModel authRequestModel) => userApiClient.request( requestType: RequestType.post, path: ApiEndpoints.login, body: authRequestModel.toMap(), - options: Options( - headers: { - 'x-api-key': 'reqres-free-v1', - 'Content-Type': 'application/json', - }, - ), + options: Options(headers: {'x-api-key': 'reqres-free-v1', 'Content-Type': 'application/json'}), ); - TaskEither saveUserToLocal( - AuthResponseModel authResponseModel, - ) => getIt().setUserData( - UserModel( - name: 'user name', - email: 'user email', - profilePicUrl: '', - id: int.parse(authResponseModel.id), - ), + TaskEither saveUserToLocal(AuthResponseModel authResponseModel) => getIt().setUserData( + UserModel(name: 'user name', email: 'user email', profilePicUrl: '', id: int.parse(authResponseModel.id)), ); @override - TaskEither signup( - AuthRequestModel authRequestModel, - ) => makeSignUpRequest(authRequestModel) + TaskEither signup(AuthRequestModel authRequestModel) => makeSignUpRequest(authRequestModel) .chainEither(RepositoryUtils.checkStatusCode) .chainEither( (r) => RepositoryUtils.mapToModel(() { @@ -82,27 +64,20 @@ class AuthRepository implements IAuthRepository { // return AuthResponseModel.fromMap( // r.data as Map, // ); - return AuthResponseModel( - email: 'eve.holt@reqres.in', - id: (r.data as Map)['id'].toString(), - ); + return AuthResponseModel(email: 'eve.holt@reqres.in', id: (r.data as Map)['id'].toString()); }), ) .flatMap(saveUserToLocal); - TaskEither makeSignUpRequest( - AuthRequestModel authRequestModel, - ) => userApiClient.request( + TaskEither makeSignUpRequest(AuthRequestModel authRequestModel) => userApiClient.request( requestType: RequestType.post, path: ApiEndpoints.signup, body: authRequestModel.toMap(), options: Options(headers: {'Content-Type': 'application/json'}), ); - TaskEither _clearHiveData() => TaskEither.tryCatch( - () => getIt().logout().run(), - (error, stackTrace) => APIFailure(), - ); + TaskEither _clearHiveData() => + TaskEither.tryCatch(() => getIt().logout().run(), (error, stackTrace) => APIFailure()); @override TaskEither logout() => makeLogoutRequest().flatMap( @@ -111,14 +86,11 @@ class AuthRepository implements IAuthRepository { }), ); - TaskEither _getNotificationId() => - TaskEither.tryCatch(() { - return getIt() - .getNotificationSubscriptionId(); - }, APIFailure.new); + TaskEither _getNotificationId() => TaskEither.tryCatch(() { + return getIt().getNotificationSubscriptionId(); + }, APIFailure.new); - TaskEither - makeLogoutRequest() => _getNotificationId().flatMap( + TaskEither makeLogoutRequest() => _getNotificationId().flatMap( (playerID) => userApiClient.request( requestType: RequestType.delete, @@ -130,26 +102,54 @@ class AuthRepository implements IAuthRepository { ); @override - TaskEither socialLogin({ - required AuthRequestModel requestModel, - }) => makeSocialLoginRequest(requestModel: requestModel) + TaskEither socialLogin({required AuthRequestModel requestModel}) => makeSocialLoginRequest( + requestModel: requestModel, + ) .chainEither(RepositoryUtils.checkStatusCode) .chainEither( - (response) => RepositoryUtils.mapToModel( - () => AuthResponseModel.fromMap( - response.data as Map, - ), - ), + (response) => + RepositoryUtils.mapToModel(() => AuthResponseModel.fromMap(response.data as Map)), ) .flatMap(saveUserToLocal); - TaskEither makeSocialLoginRequest({ - required AuthRequestModel requestModel, - }) { + TaskEither makeSocialLoginRequest({required AuthRequestModel requestModel}) { return userApiClient.request( requestType: RequestType.post, path: ApiEndpoints.socialLogin, body: requestModel.toSocialSignInMap(), ); } + + @override + TaskEither forgotPassword(AuthRequestModel authRequestModel) => makeForgotPasswordRequest(authRequestModel) + .chainEither(RepositoryUtils.checkStatusCode) + .chainEither( + (response) => RepositoryUtils.mapToModel(() { + return response.data; + }), + ) + .map((_) => unit); + + TaskEither makeForgotPasswordRequest(AuthRequestModel authRequestModel) => userApiClient.request( + requestType: RequestType.post, + path: ApiEndpoints.forgotPassword, + body: authRequestModel.toForgotPasswordMap(), + options: Options(headers: {'x-api-key': 'reqres-free-v1', 'Content-Type': 'application/json'}), + ); + + @override + TaskEither verifyOTP(AuthRequestModel authRequestModel) => makeVerifyOTPRequest(authRequestModel) + .chainEither(RepositoryUtils.checkStatusCode) + .chainEither( + (response) => RepositoryUtils.mapToModel(() { + return AuthResponseModel.fromMap(response.data as Map); + }), + ); + + TaskEither makeVerifyOTPRequest(AuthRequestModel authRequestModel) => userApiClient.request( + requestType: RequestType.post, + path: ApiEndpoints.verifyOTP, + body: authRequestModel.toVerifyOTPMap(), + options: Options(headers: {'Content-Type': 'application/json'}), + ); } diff --git a/apps/app_core/lib/modules/auth/sign_in/screens/sign_in_screen.dart b/apps/app_core/lib/modules/auth/sign_in/screens/sign_in_screen.dart index 94dd557..2fd5c22 100644 --- a/apps/app_core/lib/modules/auth/sign_in/screens/sign_in_screen.dart +++ b/apps/app_core/lib/modules/auth/sign_in/screens/sign_in_screen.dart @@ -27,12 +27,7 @@ class SignInPage extends StatelessWidget implements AutoRouteWrapper { providers: [RepositoryProvider(create: (context) => const AuthRepository())], child: MultiBlocProvider( providers: [ - BlocProvider( - create: - (context) => SignInBloc( - authenticationRepository: RepositoryProvider.of(context), - ), - ), + BlocProvider(create: (context) => SignInBloc(authenticationRepository: RepositoryProvider.of(context))), ], child: this, ), @@ -61,21 +56,32 @@ class SignInPage extends StatelessWidget implements AutoRouteWrapper { children: [ VSpace.xxxxlarge80(), VSpace.large24(), - const SlideAndFadeAnimationWrapper( - delay: 100, - child: Center(child: FlutterLogo(size: 100)), - ), + const SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: FlutterLogo(size: 100))), VSpace.xxlarge40(), VSpace.large24(), - SlideAndFadeAnimationWrapper( - delay: 200, - child: AppText.XL(text: context.t.sign_in), - ), + SlideAndFadeAnimationWrapper(delay: 200, child: AppText.XL(text: context.t.sign_in)), VSpace.large24(), SlideAndFadeAnimationWrapper(delay: 300, child: _EmailInput()), VSpace.large24(), SlideAndFadeAnimationWrapper(delay: 400, child: _PasswordInput()), VSpace.large24(), + AnimatedGestureDetector( + onTap: () { + context.pushRoute(const ForgotPasswordRoute()); + }, + child: SlideAndFadeAnimationWrapper( + delay: 200, + child: Align( + alignment: Alignment.topRight, + child: AppText.regular10( + fontSize: 14, + text: context.t.forgot_password, + color: context.colorScheme.primary400, + ), + ), + ), + ), + VSpace.large24(), SlideAndFadeAnimationWrapper(delay: 400, child: _UserConsentWidget()), VSpace.xxlarge40(), const SlideAndFadeAnimationWrapper(delay: 500, child: _LoginButton()), @@ -84,8 +90,7 @@ class SignInPage extends StatelessWidget implements AutoRouteWrapper { VSpace.large24(), const SlideAndFadeAnimationWrapper(delay: 600, child: _ContinueWithGoogleButton()), VSpace.large24(), - if (Platform.isIOS) - const SlideAndFadeAnimationWrapper(delay: 600, child: _ContinueWithAppleButton()), + if (Platform.isIOS) const SlideAndFadeAnimationWrapper(delay: 600, child: _ContinueWithAppleButton()), ], ), ), @@ -125,8 +130,7 @@ class _PasswordInput extends StatelessWidget { label: context.t.password, textInputAction: TextInputAction.done, onChanged: (password) => context.read().add(SignInPasswordChanged(password)), - errorText: - state.password.displayError != null ? context.t.common_validation_password : null, + errorText: state.password.displayError != null ? context.t.common_validation_password : null, autofillHints: const [AutofillHints.password], ); }, @@ -143,9 +147,7 @@ class _UserConsentWidget extends StatelessWidget { return UserConsentWidget( value: isUserConsent, onCheckBoxValueChanged: (userConsent) { - context.read().add( - SignInUserConsentChangedEvent(userConsent: userConsent ?? false), - ); + context.read().add(SignInUserConsentChangedEvent(userConsent: userConsent ?? false)); }, onTermsAndConditionTap: () => launchUrl( diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart new file mode 100644 index 0000000..58efaba --- /dev/null +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_bloc.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:app_core/core/domain/validators/login_validators.dart'; +import 'package:app_core/modules/auth/model/auth_request_model.dart'; +import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; +import 'package:fpdart/fpdart.dart'; + +part 'forgot_password_event.dart'; +part 'forgot_password_state.dart'; + +class ForgotPasswordBloc extends Bloc { + ForgotPasswordBloc({required IAuthRepository authenticationRepository}) + : _authenticationRepository = authenticationRepository, + super(const ForgotPasswordState()) { + on(_onEmailChanged); + on(_onSubmitted); + } + + final IAuthRepository _authenticationRepository; + + void _onEmailChanged(ForgotPasswordEmailChanged event, Emitter emit) { + final email = EmailValidator.dirty(event.email.trim()); + emit(state.copyWith(email: email, isValid: Formz.validate([email]))); + } + + Future _onSubmitted(ForgotPasswordSubmitted event, Emitter emit) async { + final email = EmailValidator.dirty(state.email.value); + emit(state.copyWith(email: email, isValid: Formz.validate([email]))); + if (state.isValid) { + emit(state.copyWith(status: FormzSubmissionStatus.inProgress)); + final result = + await _authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email.value)).run(); + result.fold( + (failure) => emit(state.copyWith(status: FormzSubmissionStatus.failure)), + (success) => emit(state.copyWith(status: FormzSubmissionStatus.success)), + ); + } + return unit; + } +} diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_event.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_event.dart new file mode 100644 index 0000000..8242159 --- /dev/null +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_event.dart @@ -0,0 +1,21 @@ +part of 'forgot_password_bloc.dart'; + +sealed class ForgotPasswordEvent extends Equatable { + const ForgotPasswordEvent(); + + @override + List get props => []; +} + +final class ForgotPasswordEmailChanged extends ForgotPasswordEvent { + const ForgotPasswordEmailChanged(this.email); + + final String email; + + @override + List get props => [email]; +} + +final class ForgotPasswordSubmitted extends ForgotPasswordEvent { + const ForgotPasswordSubmitted(); +} diff --git a/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart new file mode 100644 index 0000000..b9d336c --- /dev/null +++ b/apps/app_core/lib/modules/forgot_password/bloc/forgot_password_state.dart @@ -0,0 +1,27 @@ +part of 'forgot_password_bloc.dart'; + +final class ForgotPasswordState extends Equatable { + const ForgotPasswordState({ + this.status = FormzSubmissionStatus.initial, + this.email = const EmailValidator.pure(), + this.isValid = false, + this.errorMessage = '', + }); + + ForgotPasswordState copyWith({EmailValidator? email, bool? isValid, FormzSubmissionStatus? status, String? errorMessage}) { + return ForgotPasswordState( + email: email ?? this.email, + isValid: isValid ?? this.isValid, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + ); + } + + final FormzSubmissionStatus status; + final EmailValidator email; + final bool isValid; + final String errorMessage; + + @override + List get props => [status, email, isValid, errorMessage]; +} diff --git a/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart b/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart new file mode 100644 index 0000000..429cd4d --- /dev/null +++ b/apps/app_core/lib/modules/forgot_password/screens/forgot_password_screen.dart @@ -0,0 +1,103 @@ +import 'package:app_core/app/routes/app_router.dart'; +import 'package:app_core/core/presentation/widgets/app_snackbar.dart'; +import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:app_core/modules/forgot_password/bloc/forgot_password_bloc.dart'; +import 'package:app_translations/app_translations.dart'; +import 'package:app_ui/app_ui.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:formz/formz.dart'; + +@RoutePage() +class ForgotPasswordPage extends StatelessWidget implements AutoRouteWrapper { + const ForgotPasswordPage({super.key}); + + @override + Widget wrappedRoute(BuildContext context) { + return RepositoryProvider( + create: (context) => const AuthRepository(), + child: BlocProvider( + create: (context) => ForgotPasswordBloc(authenticationRepository: RepositoryProvider.of(context)), + child: this, + ), + ); + } + + @override + Widget build(BuildContext context) { + return AppScaffold( + body: BlocListener( + listenWhen: (previous, current) => previous.status != current.status, + listener: (context, state) async { + if (state.status.isFailure) { + showAppSnackbar(context, state.errorMessage); + } else if (state.status.isSuccess) { + showAppSnackbar(context, context.t.reset_password_mail_sent); + await context.replaceRoute(VerifyOTPRoute(emailAddress: state.email.value)); + } + }, + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: Insets.large24), + + children: [ + VSpace.xxxlarge66(), + SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), + VSpace.large24(), + SlideAndFadeAnimationWrapper( + delay: 200, + child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + ), + VSpace.large24(), + SlideAndFadeAnimationWrapper(delay: 300, child: _EmailInput()), + VSpace.xxlarge40(), + const SlideAndFadeAnimationWrapper(delay: 500, child: _ForgotPasswordButton()), + VSpace.large24(), + ], + ), + ), + ); + } +} + +class _EmailInput extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (previous, current) => previous.email != current.email, + builder: (context, state) { + return AppTextField( + textInputAction: TextInputAction.done, + initialValue: state.email.value, + label: context.t.email, + keyboardType: TextInputType.emailAddress, + onChanged: (email) => context.read().add(ForgotPasswordEmailChanged(email)), + errorText: state.email.displayError != null ? context.t.common_validation_email : null, + autofillHints: const [AutofillHints.email], + ); + }, + ); + } +} + +class _ForgotPasswordButton extends StatelessWidget { + const _ForgotPasswordButton(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return AppButton( + isLoading: state.status.isInProgress, + text: context.t.reset_password, + onPressed: () { + TextInput.finishAutofillContext(); + context.read().add(const ForgotPasswordSubmitted()); + }, + isExpanded: true, + ); + }, + ); + } +} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart new file mode 100644 index 0000000..cb63515 --- /dev/null +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_bloc.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:api_client/api_client.dart'; +import 'package:app_core/core/domain/validators/length_validator.dart'; +import 'package:app_core/modules/auth/model/auth_request_model.dart'; +import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:app_core/modules/verify_otp/bloc/verify_otp_state.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:fpdart/fpdart.dart'; + +part 'verify_otp_event.dart'; + +class VerifyOTPBloc extends Bloc { + VerifyOTPBloc(this.authenticationRepository) : super(const VerifyOTPState()) { + on(_onSetEmail); + on(_onVerifyButtonPressed); + on(_onVerifyOTPChanged); + on(_onResendEmail); + on((event, emit) { + emit(state.copyWith(isTimerRunning: true)); + }); + on((event, emit) { + emit(state.copyWith(isTimerRunning: false)); + }); + } + + final AuthRepository authenticationRepository; + + void _onSetEmail(SetEmailEvent event, Emitter emit) { + emit(state.copyWith(email: event.email)); + } + + Future _onVerifyButtonPressed(VerifyButtonPressed event, Emitter emit) async { + emit(state.copyWith(verifyOtpStatus: ApiStatus.loading, resendOtpStatus: ApiStatus.initial)); + // Static OTP check for now + if (state.otp.value == '222222') { + emit(state.copyWith( + verifyOtpStatus: ApiStatus.loaded, + resendOtpStatus: ApiStatus.initial, + errorMessage: 'OTP verified successfully!', + )); + } else { + emit(state.copyWith( + verifyOtpStatus: ApiStatus.error, + resendOtpStatus: ApiStatus.initial, + errorMessage: 'Invalid OTP', + )); + } + return unit; + } + + final int _otpLength = 6; + Future _onVerifyOTPChanged(VerifyOTPChanged event, Emitter emit) async { + final otp = LengthValidator.dirty(_otpLength, event.otp); + emit(state.copyWith(otp: otp, verifyOtpStatus: ApiStatus.initial, resendOtpStatus: ApiStatus.initial)); + return unit; + } + + Future _onResendEmail(ResendEmailEvent event, Emitter emit) async { + emit( + state.copyWith( + verifyOtpStatus: ApiStatus.initial, + resendOtpStatus: ApiStatus.loading, + otp: LengthValidator.pure(_otpLength), + ), + ); + final response = + await authenticationRepository.forgotPassword(AuthRequestModel.forgotPassword(email: state.email)).run(); + + response.fold( + (failure) { + emit(state.copyWith(resendOtpStatus: ApiStatus.error, verifyOtpStatus: ApiStatus.initial, errorMessage: failure.message)); + }, + (success) { + emit(state.copyWith(verifyOtpStatus: ApiStatus.initial, resendOtpStatus: ApiStatus.loaded)); + add(const StartTimerEvent()); + }, + ); + return unit; + } +} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart new file mode 100644 index 0000000..645945f --- /dev/null +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_event.dart @@ -0,0 +1,47 @@ +part of 'verify_otp_bloc.dart'; + +sealed class VerifyOTPEvent extends Equatable { + const VerifyOTPEvent(); + + @override + List get props => []; +} + +final class VerifyOTPChanged extends VerifyOTPEvent { + const VerifyOTPChanged(this.otp); + final String otp; + + @override + List get props => [otp]; +} + +final class EmailAddressChanged extends VerifyOTPEvent { + const EmailAddressChanged(this.email); + final String email; + @override + List get props => [email]; +} + +final class VerifyButtonPressed extends VerifyOTPEvent { + const VerifyButtonPressed(); +} + +final class ResendEmailEvent extends VerifyOTPEvent { + const ResendEmailEvent(); +} + +class SetEmailEvent extends VerifyOTPEvent { + const SetEmailEvent(this.email); + final String email; + + @override + List get props => [email]; +} + +class StartTimerEvent extends VerifyOTPEvent { + const StartTimerEvent(); +} + +class TimerFinishedEvent extends VerifyOTPEvent { + const TimerFinishedEvent(); +} diff --git a/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart new file mode 100644 index 0000000..59d9906 --- /dev/null +++ b/apps/app_core/lib/modules/verify_otp/bloc/verify_otp_state.dart @@ -0,0 +1,44 @@ +import 'package:api_client/api_client.dart'; +import 'package:app_core/core/domain/validators/length_validator.dart'; +import 'package:equatable/equatable.dart'; + +final class VerifyOTPState extends Equatable { + const VerifyOTPState({ + this.resendOtpStatus = ApiStatus.initial, + this.verifyOtpStatus = ApiStatus.initial, + this.email = '', + this.errorMessage = '', + this.otp = const LengthValidator.pure(6), + this.isTimerRunning = true, + }); + + VerifyOTPState copyWith({ + String? email, + LengthValidator? otp, + ApiStatus? resendOtpStatus, + ApiStatus? verifyOtpStatus, + String? errorMessage, + bool? isTimerRunning, + }) { + return VerifyOTPState( + email: email ?? this.email, + otp: otp ?? this.otp, + resendOtpStatus: resendOtpStatus ?? this.resendOtpStatus, + verifyOtpStatus: verifyOtpStatus ?? this.verifyOtpStatus, + errorMessage: errorMessage ?? this.errorMessage, + isTimerRunning: isTimerRunning ?? this.isTimerRunning, + ); + } + + final ApiStatus resendOtpStatus; + final ApiStatus verifyOtpStatus; + final String email; + final String errorMessage; + final LengthValidator otp; + final bool isTimerRunning; + + bool get isValid => otp.isValid; + + @override + List get props => [resendOtpStatus, email, otp, errorMessage, verifyOtpStatus, isTimerRunning]; +} diff --git a/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart new file mode 100644 index 0000000..15f07f2 --- /dev/null +++ b/apps/app_core/lib/modules/verify_otp/screens/verify_otp_screen.dart @@ -0,0 +1,124 @@ +import 'package:api_client/api_client.dart'; +import 'package:app_core/app/routes/app_router.dart'; +import 'package:app_core/core/presentation/widgets/app_snackbar.dart'; +import 'package:app_core/modules/auth/repository/auth_repository.dart'; +import 'package:app_core/modules/verify_otp/bloc/verify_otp_bloc.dart'; +import 'package:app_core/modules/verify_otp/bloc/verify_otp_state.dart'; +import 'package:app_translations/app_translations.dart'; +import 'package:app_ui/app_ui.dart'; +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +@RoutePage() +class VerifyOTPScreen extends StatefulWidget implements AutoRouteWrapper { + const VerifyOTPScreen({required this.emailAddress, super.key}); + + final String emailAddress; + + @override + State createState() => _VerifyOTPScreenState(); + + @override + Widget wrappedRoute(BuildContext context) { + return RepositoryProvider( + create: (context) => const AuthRepository(), + child: BlocProvider( + create: (context) => VerifyOTPBloc(RepositoryProvider.of(context))..add(SetEmailEvent(emailAddress)), + child: this, + ), + ); + } +} + +class _VerifyOTPScreenState extends State with TickerProviderStateMixin { + @override + Widget build(BuildContext context) { + return AppScaffold( + appBar: CustomAppBar( + backgroundColor: context.colorScheme.white, + automaticallyImplyLeading: true, + title: context.t.verify_otp, + ), + body: BlocConsumer( + listener: (context, state) { + if (state.verifyOtpStatus == ApiStatus.loaded && state.otp.value == '222222') { + showAppSnackbar(context, 'OTP verified successfully!'); + context.replaceRoute(const ChangePasswordRoute()); + } else if (state.verifyOtpStatus == ApiStatus.error) { + showAppSnackbar(context, 'Invalid OTP', type: SnackbarType.failed); + } + }, + builder: (context, state) { + return ListView( + padding: const EdgeInsets.all(Insets.small12), + children: [ + VSpace.large24(), + SlideAndFadeAnimationWrapper(delay: 100, child: Center(child: Assets.images.logo.image(width: 100))), + VSpace.large24(), + SlideAndFadeAnimationWrapper( + delay: 200, + child: Center(child: AppText.xsSemiBold(text: context.t.welcome, fontSize: 16)), + ), + VSpace.large24(), + AppTextField(initialValue: widget.emailAddress, label: context.t.email, isReadOnly: true), + VSpace.medium16(), + Center( + child: Padding( + padding: const EdgeInsets.all(Insets.small12), + child: AppText.sSemiBold(text: context.t.enter_otp), + ), + ), + VSpace.small12(), + AppOtpInput( + errorText: state.otp.error != null ? context.t.pin_incorrect : null, + onChanged: (value) { + context.read().add(VerifyOTPChanged(value)); + }, + ), + + VSpace.xsmall8(), + if (state.isTimerRunning) + Center( + child: AppTimer( + seconds: 30, + onFinished: () { + context.read().add(const TimerFinishedEvent()); + }, + ), + ), + VSpace.small12(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppText.xsRegular(color: context.colorScheme.black, text: context.t.did_not_receive_otp), + AppButton( + text: context.t.resend_otp, + buttonType: ButtonType.text, + textColor: context.colorScheme.primary400, + onPressed: + state.isTimerRunning + ? null + : () { + FocusScope.of(context).unfocus(); + context.read().add(const ResendEmailEvent()); + }, + ), + HSpace.xsmall8(), + ], + ), + VSpace.large24(), + AppButton( + isExpanded: true, + padding: const EdgeInsets.symmetric(horizontal: Insets.large24), + text: context.t.verify_otp, + isLoading: state.verifyOtpStatus == ApiStatus.loading, + onPressed: () => context.read().add(const VerifyButtonPressed()), + ), + ], + ); + }, + ), + ); + } +} diff --git a/apps/app_core/pubspec.yaml b/apps/app_core/pubspec.yaml index 622508f..b019f23 100644 --- a/apps/app_core/pubspec.yaml +++ b/apps/app_core/pubspec.yaml @@ -90,6 +90,7 @@ dependencies: # Launch URL url_launcher: ^6.3.1 + dependency_overrides: web: ^1.0.0 source_gen: ^2.0.0 @@ -121,6 +122,8 @@ dev_dependencies: flutter_launcher_icons: ^0.14.3 + + flutter_gen: output: lib/gen/ line_length: 80 diff --git a/packages/app_translations/assets/i18n/en.i18n.json b/packages/app_translations/assets/i18n/en.i18n.json index 83a146e..1e0c8c4 100644 --- a/packages/app_translations/assets/i18n/en.i18n.json +++ b/packages/app_translations/assets/i18n/en.i18n.json @@ -54,5 +54,15 @@ "terms_and_condition" : "Terms and Condition", "privacy_policy" : "Privacy Policy", "and": "and", - "please_accept_terms" : "Please accept the Terms & Conditions and Privacy Policy to continue." + "please_accept_terms" : "Please accept the Terms & Conditions and Privacy Policy to continue.", + "reset_password_mail_sent": "Reset password mail sent", + "welcome": "Welcome", + "reset_password": "Reset Password", + "go_back": "Go Back", + "enter_otp": "Enter OTP", + "verify_otp": "Verify OTP", + "resend_otp": "Resend OTP", + "otp_send_to_email": "OTP sent to your email", + "did_not_receive_otp": "Didn't receive the verification OTP?", + "pin_incorrect": "Pin is incorrect" } \ No newline at end of file diff --git a/packages/app_ui/lib/src/widgets/molecules/app_otp_input.dart b/packages/app_ui/lib/src/widgets/molecules/app_otp_input.dart new file mode 100644 index 0000000..c0ecb4e --- /dev/null +++ b/packages/app_ui/lib/src/widgets/molecules/app_otp_input.dart @@ -0,0 +1,23 @@ +import 'package:app_ui/app_ui.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:pinput/pinput.dart'; + +class AppOtpInput extends StatelessWidget { + const AppOtpInput({required this.onChanged, this.length = 6, this.errorText, super.key}); + + final void Function(String) onChanged; + final int length; + final String? errorText; + + @override + Widget build(BuildContext context) { + return Pinput( + length: length, + separatorBuilder: (index) => HSpace.xxsmall4(), + errorText: errorText, + onChanged: onChanged, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ); + } +} diff --git a/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart b/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart index e57afbf..b3c96c2 100644 --- a/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart +++ b/packages/app_ui/lib/src/widgets/molecules/app_textfield.dart @@ -9,6 +9,7 @@ class AppTextField extends StatefulWidget { this.textInputAction = TextInputAction.next, this.showLabel = true, this.hintText, + this.isReadOnly, this.keyboardType, this.initialValue, this.onChanged, @@ -21,8 +22,8 @@ class AppTextField extends StatefulWidget { this.autofillHints, this.hintTextBelowTextField, this.maxLength, - }) : isPasswordField = false, - isObscureText = false; + }) : isPasswordField = false, + isObscureText = false; const AppTextField.password({ required this.label, @@ -38,16 +39,18 @@ class AppTextField extends StatefulWidget { this.backgroundColor, this.minLines, this.focusNode, + this.isReadOnly, this.autofillHints, this.hintTextBelowTextField, this.contentPadding, this.maxLength, - }) : isPasswordField = true, - isObscureText = true; + }) : isPasswordField = true, + isObscureText = true; final String label; final String? initialValue; final String? hintText; + final bool? isReadOnly; final String? errorText; final String? hintTextBelowTextField; final TextInputAction? textInputAction; @@ -88,10 +91,7 @@ class _AppTextFieldState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - if (widget.showLabel) ...[ - AppText.xsSemiBold(text: widget.label), - VSpace.xsmall8(), - ], + if (widget.showLabel) ...[AppText.xsSemiBold(text: widget.label), VSpace.xsmall8()], TextFormField( initialValue: widget.initialValue, cursorColor: context.colorScheme.black, @@ -101,6 +101,7 @@ class _AppTextFieldState extends State { validator: widget.validator, obscureText: isObscureText, onChanged: widget.onChanged, + readOnly: widget.isReadOnly ?? false, autofillHints: widget.autofillHints, focusNode: widget.focusNode, maxLength: widget.maxLength, @@ -108,36 +109,27 @@ class _AppTextFieldState extends State { filled: true, fillColor: widget.backgroundColor ?? context.colorScheme.grey100, hintText: widget.hintText, - contentPadding: widget.contentPadding ?? - const EdgeInsets.only(left: Insets.small12, right: Insets.small12), + contentPadding: widget.contentPadding ?? const EdgeInsets.only(left: Insets.small12, right: Insets.small12), errorMaxLines: 2, focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(Insets.xsmall8), borderSide: BorderSide(color: context.colorScheme.primary400), ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(Insets.xsmall8), - borderSide: BorderSide.none, - ), + border: OutlineInputBorder(borderRadius: BorderRadius.circular(Insets.xsmall8), borderSide: BorderSide.none), errorText: widget.errorText, - suffixIcon: widget.isPasswordField - ? IconButton( - splashColor: context.colorScheme.primary50, - onPressed: toggleObscureText, - icon: Icon( - isObscureText ? Icons.visibility_off : Icons.visibility, - color: context.colorScheme.grey700, - ), - ) - : null, + suffixIcon: + widget.isPasswordField + ? IconButton( + splashColor: context.colorScheme.primary50, + onPressed: toggleObscureText, + icon: Icon(isObscureText ? Icons.visibility_off : Icons.visibility, color: context.colorScheme.grey700), + ) + : null, ), minLines: widget.minLines, maxLines: widget.minLines ?? 0 + 1, ), - if (widget.hintTextBelowTextField != null) ...[ - VSpace.xsmall8(), - AppText.xsRegular(text: widget.hintTextBelowTextField), - ], + if (widget.hintTextBelowTextField != null) ...[VSpace.xsmall8(), AppText.xsRegular(text: widget.hintTextBelowTextField)], ], ); } diff --git a/packages/app_ui/lib/src/widgets/molecules/app_timer.dart b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart new file mode 100644 index 0000000..af029e9 --- /dev/null +++ b/packages/app_ui/lib/src/widgets/molecules/app_timer.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:app_ui/app_ui.dart'; +import 'package:flutter/material.dart'; + +class AppTimer extends StatefulWidget { + const AppTimer({required this.seconds, super.key, this.onFinished}) : assert(seconds >= 0, 'seconds must be non-negative'); + final int seconds; + + final VoidCallback? onFinished; + + @override + State createState() => _AppTimerState(); +} + +class _AppTimerState extends State { + late int _secondsRemaining; + Timer? _timer; + + @override + void initState() { + super.initState(); + _secondsRemaining = widget.seconds; + _startTimer(); + } + + @override + void didUpdateWidget(covariant AppTimer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.seconds != widget.seconds) { + _timer?.cancel(); + _secondsRemaining = widget.seconds; + _startTimer(); + } + } + + void _startTimer() { + if (widget.seconds == 0) return; + _timer?.cancel(); + _timer = Timer.periodic(const Duration(seconds: 1), (timer) { + if (_secondsRemaining > 0) { + setState(() { + _secondsRemaining--; + }); + } else { + timer.cancel(); + widget.onFinished?.call(); + } + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final minutes = _secondsRemaining ~/ 60; + final seconds = _secondsRemaining % 60; + final timerText = '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}'; + return AppText(text: timerText, style: context.textTheme?.sSemiBold.copyWith(color: context.colorScheme.primary400)); + } +} diff --git a/packages/app_ui/lib/src/widgets/molecules/molecules.dart b/packages/app_ui/lib/src/widgets/molecules/molecules.dart index 545f7ed..a8e4f56 100644 --- a/packages/app_ui/lib/src/widgets/molecules/molecules.dart +++ b/packages/app_ui/lib/src/widgets/molecules/molecules.dart @@ -4,9 +4,11 @@ export 'app_circular_progress_indicator.dart'; export 'app_dialog.dart'; export 'app_dropdown.dart'; export 'app_network_image.dart'; +export 'app_otp_input.dart'; export 'app_profile_image.dart'; export 'app_refresh_indicator.dart'; export 'app_textfield.dart'; +export 'app_timer.dart'; export 'empty_ui.dart'; export 'no_internet_widget.dart'; export 'user_concent_widget.dart'; diff --git a/packages/app_ui/pubspec.yaml b/packages/app_ui/pubspec.yaml index 085f719..31c4983 100644 --- a/packages/app_ui/pubspec.yaml +++ b/packages/app_ui/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: timeago: ^3.7.0 url_launcher: ^6.3.1 flutter_svg: ^2.0.17 + pinput: ^5.0.1 dev_dependencies: flutter_test: diff --git a/packages/widgetbook/pubspec.lock b/packages/widgetbook/pubspec.lock index 1ba8abf..6c60a02 100644 --- a/packages/widgetbook/pubspec.lock +++ b/packages/widgetbook/pubspec.lock @@ -52,10 +52,10 @@ packages: dependency: transitive description: name: async - sha256: d2872f9c19731c2e5f10444b14686eb7cc85c76274bd6c16e1816bff9a3bab63 + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" url: "https://pub.dev" source: hosted - version: "2.12.0" + version: "2.13.0" boolean_selector: dependency: transitive description: @@ -236,10 +236,10 @@ packages: dependency: transitive description: name: fake_async - sha256: "6a95e56b2449df2273fd8c45a662d6947ce1ebb7aafe80e550a3f68297f3cacc" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.3.3" ffi: dependency: transitive description: @@ -419,10 +419,10 @@ packages: dependency: transitive description: name: leak_tracker - sha256: c35baad643ba394b40aac41080300150a4f08fd0fd6a10378f8f7c6bc161acec + sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0" url: "https://pub.dev" source: hosted - version: "10.0.8" + version: "10.0.9" leak_tracker_flutter_testing: dependency: transitive description: @@ -583,6 +583,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + pinput: + dependency: transitive + description: + name: pinput + sha256: "8a73be426a91fefec90a7f130763ca39772d547e92f19a827cf4aa02e323d35a" + url: "https://pub.dev" + source: hosted + version: "5.0.1" platform: dependency: transitive description: @@ -659,10 +667,10 @@ packages: dependency: transitive description: name: skeletonizer - sha256: "0dcacc51c144af4edaf37672072156f49e47036becbc394d7c51850c5c1e884b" + sha256: a9ddf63900947f4c0648372b6e9987bc2b028db9db843376db6767224d166c31 url: "https://pub.dev" source: hosted - version: "1.4.3" + version: "2.0.1" sky_engine: dependency: transitive description: flutter @@ -812,6 +820,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" url_launcher: dependency: transitive description: @@ -920,10 +936,10 @@ packages: dependency: transitive description: name: vm_service - sha256: "0968250880a6c5fe7edc067ed0a13d4bae1577fe2771dcf3010d52c4a9d3ca14" + sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02 url: "https://pub.dev" source: hosted - version: "14.3.1" + version: "15.0.0" watcher: dependency: transitive description: @@ -1006,4 +1022,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.7.0 <4.0.0" - flutter: ">=3.29.0" + flutter: ">=3.31.0-0.0.pre" diff --git a/packages/widgetbook/pubspec_overrides.yaml b/packages/widgetbook/pubspec_overrides.yaml index 32cda75..1cf53a3 100644 --- a/packages/widgetbook/pubspec_overrides.yaml +++ b/packages/widgetbook/pubspec_overrides.yaml @@ -1,5 +1,4 @@ -# melos_managed_dependency_overrides: app_ui,skeletonizer +# melos_managed_dependency_overrides: app_ui dependency_overrides: app_ui: path: ../app_ui - skeletonizer: ^2.0.0-pre