feat: Refactor code structure and optimize performance across multiple modules
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:injectable/injectable.dart';
|
||||
import 'package:krow/core/data/static/email_validation_constants.dart';
|
||||
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
@injectable
|
||||
class EmailVerificationService {
|
||||
static const recentLoginRequired = 'requires-recent-login';
|
||||
static const reLoginRequired = 'requires-re-login';
|
||||
static const tokenExpired = 'user-token-expired';
|
||||
|
||||
final FirebaseAuth _auth = FirebaseAuth.instance;
|
||||
String? _currentUserPhone;
|
||||
|
||||
String get currentUserPhone {
|
||||
_currentUserPhone ??= _auth.currentUser?.phoneNumber;
|
||||
|
||||
return _currentUserPhone ?? '';
|
||||
}
|
||||
|
||||
Future<void> sendVerificationEmail({required String email}) async {
|
||||
log('Sending email verification $email');
|
||||
|
||||
await _auth.currentUser?.verifyBeforeUpdateEmail(
|
||||
email,
|
||||
// TODO: Enabling this code will require enabling Firebase Dynamic Links.
|
||||
// ActionCodeSettings(
|
||||
// url: 'https://staging.app.krow.develop.express/emailLinkAuth/',
|
||||
// androidPackageName: dotenv.get('ANDROID_PACKAGE'),
|
||||
// iOSBundleId: dotenv.get('IOS_BUNDLE_ID'),
|
||||
// androidInstallApp: true,
|
||||
// handleCodeInApp: true,
|
||||
// linkDomain: 'krow-staging.firebaseapp.com',
|
||||
// ),
|
||||
);
|
||||
|
||||
final sharedPrefs = await SharedPreferences.getInstance();
|
||||
|
||||
sharedPrefs.setString(
|
||||
EmailValidationConstants.storedEmailKey,
|
||||
email,
|
||||
);
|
||||
}
|
||||
|
||||
bool checkEmailForVerification({required String email}) {
|
||||
final user = _auth.currentUser;
|
||||
|
||||
if (user == null || user.email == null) return false;
|
||||
|
||||
return email == user.email && user.emailVerified;
|
||||
}
|
||||
|
||||
Future<bool> isUserEmailVerified({
|
||||
required String newEmail,
|
||||
}) async {
|
||||
await _auth.currentUser?.reload();
|
||||
final user = _auth.currentUser;
|
||||
|
||||
log(
|
||||
'Current user email: ${user?.email}, '
|
||||
'verified: ${user?.emailVerified}, '
|
||||
'Expected email: $newEmail',
|
||||
);
|
||||
if (user == null) {
|
||||
throw FirebaseAuthException(code: reLoginRequired);
|
||||
}
|
||||
return (user.emailVerified) && user.email == newEmail;
|
||||
}
|
||||
|
||||
ReAuthRequirement isReAuthenticationRequired(String errorCode) {
|
||||
return switch (errorCode) {
|
||||
recentLoginRequired => ReAuthRequirement.recent,
|
||||
reLoginRequired || tokenExpired => ReAuthRequirement.immediate,
|
||||
_ => ReAuthRequirement.none,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/di/injectable.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/core/sevices/auth_state_service/auth_service.dart';
|
||||
import 'package:krow/features/profile/email_verification/data/email_verification_service.dart';
|
||||
|
||||
part 'email_verification_event.dart';
|
||||
|
||||
part 'email_verification_state.dart';
|
||||
|
||||
/// This BLoC is currently unfinished.
|
||||
/// We faced an issue with the Firebase email verification process. As a
|
||||
/// security-sensitive action, it requires recent user authentication (seems
|
||||
/// like around 5-10 minutes), so we have to prompt the user to re-login. Only
|
||||
/// after that can the verification email be sent.
|
||||
/// But upon receiving the email and confirming it, another issue arises: the
|
||||
/// current auth token becomes invalid, and the user is silently signed out from
|
||||
/// Firebase. On mobile, it can be solved by integrating a deep-link into the
|
||||
/// verification email, which will allow us to re-authenticate the user with
|
||||
/// an email link credential in the background.
|
||||
/// However, there is a possibility that the user might verify his email on a
|
||||
/// desktop (or any other device), so the link won't be received. In this case,
|
||||
/// we need to prompt the user to re-authenticate yet again. This will probably
|
||||
/// be a bad user experience.
|
||||
class EmailVerificationBloc
|
||||
extends Bloc<EmailVerificationEvent, EmailVerificationState> {
|
||||
EmailVerificationBloc() : super(const EmailVerificationState()) {
|
||||
on<SetVerifiedData>((event, emit) async {
|
||||
emit(state.copyWith(
|
||||
email: event.verifiedEmail,
|
||||
userPhone: event.userPhone,
|
||||
));
|
||||
|
||||
add(const SendVerificationEmail());
|
||||
});
|
||||
|
||||
on<SendVerificationEmail>((event, emit) async {
|
||||
try {
|
||||
await _verificationService.sendVerificationEmail(email: state.email);
|
||||
|
||||
emailCheckTimer?.cancel();
|
||||
emailCheckTimer = Timer.periodic(
|
||||
const Duration(seconds: 3),
|
||||
(_) => add(const CheckEmailStatus()),
|
||||
);
|
||||
} catch (except) {
|
||||
log(
|
||||
'Error in EmailVerificationBloc. On SetVerifiedEmail event',
|
||||
error: except,
|
||||
);
|
||||
|
||||
if (except is FirebaseAuthException) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
reAuthRequirement:
|
||||
_verificationService.isReAuthenticationRequired(except.code),
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
emit(state.copyWith(reAuthRequirement: ReAuthRequirement.none));
|
||||
}
|
||||
});
|
||||
|
||||
on<CheckEmailStatus>((event, emit) async {
|
||||
bool isEmailVerified = false;
|
||||
try {
|
||||
isEmailVerified = await _verificationService.isUserEmailVerified(
|
||||
newEmail: state.email,
|
||||
);
|
||||
} catch (except) {
|
||||
log(
|
||||
'Error in EmailVerificationBloc. On CheckEmailStatus event',
|
||||
error: except,
|
||||
);
|
||||
if (except is FirebaseAuthException) {
|
||||
emit(
|
||||
state.copyWith(
|
||||
reAuthRequirement:
|
||||
_verificationService.isReAuthenticationRequired(except.code),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
emit(
|
||||
state.copyWith(
|
||||
status: isEmailVerified ? StateStatus.success : StateStatus.idle,
|
||||
),
|
||||
);
|
||||
|
||||
if (state.status == StateStatus.success) emailCheckTimer?.cancel();
|
||||
});
|
||||
|
||||
on<ResendVerificationEmail>((event, emit) {
|
||||
emit(state.copyWith(reAuthRequirement: ReAuthRequirement.none));
|
||||
|
||||
add(const SendVerificationEmail());
|
||||
});
|
||||
|
||||
on<IncomingVerificationLink>((event, emit) async {
|
||||
try {
|
||||
await getIt<AuthService>().signInWithEmailLink(
|
||||
link: event.verificationLink,
|
||||
);
|
||||
|
||||
emit(state.copyWith(status: StateStatus.success));
|
||||
} catch (except) {
|
||||
log(
|
||||
'Error in EmailVerificationBloc. On IncomingVerificationLink event',
|
||||
error: except,
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status == StateStatus.success) emailCheckTimer?.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
final _verificationService = getIt<EmailVerificationService>();
|
||||
Timer? emailCheckTimer;
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
emailCheckTimer?.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
part of 'email_verification_bloc.dart';
|
||||
|
||||
@immutable
|
||||
sealed class EmailVerificationEvent {
|
||||
const EmailVerificationEvent();
|
||||
}
|
||||
|
||||
class SetVerifiedData extends EmailVerificationEvent {
|
||||
const SetVerifiedData({required this.verifiedEmail, required this.userPhone});
|
||||
|
||||
final String verifiedEmail;
|
||||
final String userPhone;
|
||||
}
|
||||
|
||||
class SendVerificationEmail extends EmailVerificationEvent {
|
||||
const SendVerificationEmail();
|
||||
}
|
||||
|
||||
class CheckEmailStatus extends EmailVerificationEvent {
|
||||
const CheckEmailStatus();
|
||||
}
|
||||
|
||||
class ResendVerificationEmail extends EmailVerificationEvent {
|
||||
const ResendVerificationEmail();
|
||||
}
|
||||
|
||||
class IncomingVerificationLink extends EmailVerificationEvent {
|
||||
const IncomingVerificationLink({required this.verificationLink});
|
||||
|
||||
final Uri verificationLink;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
part of 'email_verification_bloc.dart';
|
||||
|
||||
@immutable
|
||||
class EmailVerificationState {
|
||||
const EmailVerificationState({
|
||||
this.email = '',
|
||||
this.status = StateStatus.idle,
|
||||
this.reAuthRequirement = ReAuthRequirement.none,
|
||||
this.userPhone = '',
|
||||
});
|
||||
|
||||
final String email;
|
||||
final StateStatus status;
|
||||
final ReAuthRequirement reAuthRequirement;
|
||||
final String userPhone;
|
||||
|
||||
EmailVerificationState copyWith({
|
||||
String? email,
|
||||
StateStatus? status,
|
||||
ReAuthRequirement? reAuthRequirement,
|
||||
String? userPhone,
|
||||
}) {
|
||||
return EmailVerificationState(
|
||||
email: email ?? this.email,
|
||||
status: status ?? this.status,
|
||||
reAuthRequirement: reAuthRequirement ?? this.reAuthRequirement,
|
||||
userPhone: userPhone ?? this.userPhone,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum ReAuthRequirement { none, recent, immediate }
|
||||
@@ -0,0 +1,105 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:app_links/app_links.dart';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:gap/gap.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/core/presentation/widgets/scroll_layout_helper.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_app_bar.dart';
|
||||
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
|
||||
import 'package:krow/features/profile/email_verification/presentation/widgets/bottom_control_button.dart';
|
||||
import 'package:krow/features/profile/email_verification/presentation/widgets/verification_actions_widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class EmailVerificationScreen extends StatefulWidget
|
||||
implements AutoRouteWrapper {
|
||||
const EmailVerificationScreen({
|
||||
super.key,
|
||||
required this.verifiedEmail,
|
||||
required this.userPhone,
|
||||
});
|
||||
|
||||
final String verifiedEmail;
|
||||
final String userPhone;
|
||||
|
||||
@override
|
||||
State<EmailVerificationScreen> createState() =>
|
||||
_EmailVerificationScreenState();
|
||||
|
||||
@override
|
||||
Widget wrappedRoute(BuildContext context) {
|
||||
return BlocProvider<EmailVerificationBloc>(
|
||||
create: (context) => EmailVerificationBloc()
|
||||
..add(SetVerifiedData(
|
||||
verifiedEmail: verifiedEmail,
|
||||
userPhone: userPhone,
|
||||
)),
|
||||
child: this,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmailVerificationScreenState extends State<EmailVerificationScreen> {
|
||||
StreamSubscription<Uri>? _appLinkSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final context = this.context;
|
||||
_appLinkSubscription = AppLinks().uriLinkStream.listen(
|
||||
(link) {
|
||||
if (!context.mounted) return;
|
||||
|
||||
context
|
||||
.read<EmailVerificationBloc>()
|
||||
.add(IncomingVerificationLink(verificationLink: link));
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: KwAppBar(
|
||||
titleText: 'email_verification'.tr(),
|
||||
),
|
||||
body: ScrollLayoutHelper(
|
||||
padding: const EdgeInsets.all(16),
|
||||
upperWidget: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Gap(4),
|
||||
Text(
|
||||
'check_your_email'.tr(),
|
||||
style: AppTextStyles.headingH1,
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
const Gap(8),
|
||||
Text(
|
||||
'verification_link_sent'
|
||||
.tr(args: [widget.verifiedEmail]),
|
||||
style: AppTextStyles.bodyMediumReg.copyWith(
|
||||
color: AppColors.blackGray,
|
||||
),
|
||||
textAlign: TextAlign.start,
|
||||
),
|
||||
const Gap(24),
|
||||
const VerificationActionsWidget(),
|
||||
],
|
||||
),
|
||||
lowerWidget: const BottomControlButton(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appLinkSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/application/routing/routes.gr.dart';
|
||||
import 'package:krow/core/data/enums/state_status.dart';
|
||||
import 'package:krow/core/presentation/gen/assets.gen.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/dialogs/kw_dialog.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_button.dart';
|
||||
import 'package:krow/core/presentation/widgets/ui_kit/kw_loading_overlay.dart';
|
||||
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
|
||||
|
||||
class BottomControlButton extends StatelessWidget {
|
||||
const BottomControlButton({super.key});
|
||||
|
||||
void _listenHandler(BuildContext context, EmailVerificationState state) {
|
||||
if (state.reAuthRequirement == ReAuthRequirement.none) return;
|
||||
|
||||
KwDialog.show(
|
||||
context: context,
|
||||
icon: Assets.images.icons.alertTriangle,
|
||||
state: KwDialogState.negative,
|
||||
barrierDismissible: false,
|
||||
title: 'additional_action_needed'.tr(),
|
||||
message: state.reAuthRequirement == ReAuthRequirement.recent
|
||||
? 'email_verification_security_sensitive'.tr(args: [state.userPhone])
|
||||
: 'unable_to_validate_email_status'.tr(),
|
||||
primaryButtonLabel: 'Continue'.tr(),
|
||||
onPrimaryButtonPressed: (dialogContext) async {
|
||||
final isReAuthenticated = await dialogContext.pushRoute<bool>(
|
||||
PhoneReLoginFlowRoute(userPhone: state.userPhone),
|
||||
);
|
||||
|
||||
if (dialogContext.mounted) Navigator.maybePop(dialogContext);
|
||||
|
||||
if (isReAuthenticated == true &&
|
||||
context.mounted &&
|
||||
state.reAuthRequirement == ReAuthRequirement.recent) {
|
||||
context
|
||||
.read<EmailVerificationBloc>()
|
||||
.add(const ResendVerificationEmail());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return BlocConsumer<EmailVerificationBloc, EmailVerificationState>(
|
||||
buildWhen: (previous, current) => previous.status != current.status,
|
||||
listenWhen: (previous, current) =>
|
||||
previous.reAuthRequirement != current.reAuthRequirement,
|
||||
listener: _listenHandler,
|
||||
builder: (context, state) {
|
||||
return KwLoadingOverlay(
|
||||
shouldShowLoading: state.status == StateStatus.loading,
|
||||
child: KwButton.primary(
|
||||
label: 'Continue'.tr(),
|
||||
disabled: state.status != StateStatus.success,
|
||||
onPressed: () {
|
||||
context.maybePop(true);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:krow/core/presentation/styles/kw_text_styles.dart';
|
||||
import 'package:krow/core/presentation/styles/theme.dart';
|
||||
import 'package:krow/features/profile/email_verification/domain/bloc/email_verification_bloc.dart';
|
||||
|
||||
class VerificationActionsWidget extends StatefulWidget {
|
||||
const VerificationActionsWidget({super.key});
|
||||
|
||||
@override
|
||||
State<VerificationActionsWidget> createState() =>
|
||||
_VerificationActionsWidgetState();
|
||||
}
|
||||
|
||||
//TODO: Finish this widget incorporating increasing timer on each code resend.
|
||||
class _VerificationActionsWidgetState extends State<VerificationActionsWidget> {
|
||||
double secondsToHold = 1;
|
||||
bool onHold = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final screenHeight = MediaQuery.sizeOf(context).height;
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: screenHeight / 4.6),
|
||||
child: RichText(
|
||||
textAlign: TextAlign.center,
|
||||
text: TextSpan(
|
||||
text: 'didnt_receive'.tr(),
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'resend'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () {
|
||||
context
|
||||
.read<EmailVerificationBloc>()
|
||||
.add(const ResendVerificationEmail());
|
||||
},
|
||||
),
|
||||
TextSpan(
|
||||
text: ' ${'or'.tr()} ',
|
||||
style: AppTextStyles.bodyMediumReg
|
||||
.copyWith(color: AppColors.blackGray),
|
||||
),
|
||||
TextSpan(
|
||||
text: 'contact_support_1'.tr(),
|
||||
style: AppTextStyles.bodyMediumSmb,
|
||||
recognizer: TapGestureRecognizer()..onTap = () {
|
||||
//TODO: Add Contact support action.
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user