Passwordless sign-in with Firebase in Flutter — Part 2
In the previous chapter we implemented send sign-in link, now we cover sign-in with the link and will complete the app.
Clone the example project from the Github repository, and then checkout sign-in
tag to follow along this article:
git checkout sign-in
Sign-in the user
In the first part, we covered receiving the sign-in link. but now it's time to sign-in the user when app opens by the link.
The second API signInWithEmailLink(Uri link)
needs the sign-in link. so how we get the URL? remember we have already added FirebaseDynamicLinks
to the dependencies. it has two methods to get dynamic links:
Future<PendingDynamicLinkData?> getInitialLink()
Attempts to retrieve the dynamic link which launched the app.Stream<PendingDynamicLinkData> get onLink
Listen to incoming links when the app is open.
We need to setup a method to process all links from above methods. Update PasswordlessAuthenticator
with below codes:
...
import 'package:firebase_dynamic_links/firebase_dynamic_links.dart';
...
class PasswordlessAuthenticator {
...
final FirebaseDynamicLinks _firebaseDynamicLinks;
// 4
void Function(EmailLinkFailure value)? onSigninFailure;
PasswordlessAuthenticator(
...
this._firebaseDynamicLinks,
...
);
...
// 1
Future<void> checkEmailLink() async {
// 2
_firebaseDynamicLinks.onLink.listen(
(event) {
final result = await signInWithEmailLink(event.link);
result.fold((failure) => onSigninFailure?.call(failure), (_) => null);
},
onError: _handleLinkError,
);
try {
// 3
final linkData = await _firebaseDynamicLinks.getInitialLink();
final link = linkData?.link.toString();
if (link != null) {
signInWithEmailLink(linkData!.link);
}
} catch (e) {
_handleLinkError(e);
}
}
void _handleLinkError(Object error) {
onSigninFailure?.call(EmailLinkFailure.linkError(error.toString()));
}
}
checkEmailLink
wraps bothsonLink
andgetInitialLink
to get any available sign_in link.- Listen to any dynamic link while the app is active then passes the dynamic link to
signInWithEmailLink
method. getInitialLink
gets the link when app is launched fromnot running state
. if there is a link, it passes tosignInWithEmailLink
to handle sign-in- Failure cases is handled asynchronously meaninng we can observe the for errors later, instead of returning a failure directly from the method. we have used a closure to listen to sign-in failure errors. Another reason is
onLink
can be called anytime fromFirebaseDynamicLinks
.
Now open injection.dart
and call checkEmailLink()
on the authenticator object. So when the app launches we start listening to for sign-in link and then sign-in the user.
Future<void> configureInjection() async {
...
final authenticator = PasswordlessAuthenticator(
...
)..checkEmailLink();
...
}
Navigate to the HomePage after sign-in
Let’s update the routing so when user is signed in, the HomePage
to be displayed.
I want to mention a very important point, that navigation in mobile apps is not linear, and you should not navigate from one point to another destination when they are irrelevant. means navigation is handled by another object called coordinator or router, which can make decision for target destination.
The rule is simple navigation is not responsibility of your app UI.
Now lets go back to our scenario, GoRouter has a redirect
callback that allows to redirect to a new location based on your conditions. Its documentation says the redirect
callback can be reevaluated if we bind its BuildContext
to an InheritedWidget
. So when InheritedWidget
changes, the redirect
will be called again and then router can make decision to navigate to another path.
First Add below methods to the PasswordlessAuthenticator
to check for signed-in user:
...
import 'package:passwordless_signin/auth/models/user.dart';
...
class PasswordlessAuthenticator {
...
Option<User> getSignedInUser() =>
optionOf(_firebaseAuth.currentUser?.toDomain());
// 1
Stream<Option<User>> authStateChanges() {
return _firebaseAuth
.authStateChanges()
.map((user) => optionOf(user?.toDomain()));
}
}
extension FirebaseUserDomain on firebase_auth.User {
User toDomain() {
return User(uid);
}
}
authStateChanges
notifies us of Firebase user status change and we map the Firebase user to our domain user.
Create a file named auth_provider_scope.dart
and paste following codes:
class AuthNotifier extends ChangeNotifier {
final PasswordlessAuthenticator _auth;
bool isSignedIn = false;
AuthNotifier(
this._auth,
) {
_auth.authStateChanges().listen((user) {
isSignedIn = user.isSome();
notifyListeners();
});
}
}
AuthNotifier
is a subclass of ChangeNotifier
so the listeners can notice of authStateChanges
. Create another class named AuthProviderScope
which subclass from InheritedNotifier
which is also a subtype of InheritedWidget
:
class AuthProviderScope extends InheritedNotifier<AuthNotifier> {
const AuthProviderScope({
super.key,
required super.child,
required AuthNotifier authNotifier,
}) : super(notifier: authNotifier);
/// Gets the [AuthNotifier].
static AuthNotifier of(BuildContext context) {
return context
.dependOnInheritedWidgetOfExactType<AuthProviderScope>()!
.notifier!;
}
}
When AuthNotifier gets changed by calling notifyListeners
, AuthProviderScope
triggers dependent widget to rebuild. dependent widgets are the one that have attached their BuildContext
to the AuthProviderScope
by calling AuthProviderScope.of(context)
. context is the BuildContext
of calling widget. if you are interested to learn more about InheritedWidget mechanism, watch Decoding InheritedWidget by Flutter team.
Now open main.dart
and wrap MyApp
with AuthProviderScope
:
import 'package:passwordless_signin/auth/passwordless_authenticator.dart';
import 'package:passwordless_signin/auth_provider_scope.dart';
...
void main() async {
...
runApp(
AuthProviderScope(
authNotifier: AuthNotifier(
getIt<PasswordlessAuthenticator>(),
),
child: const MyApp(),
),
);
}
Now AuthProviderScope
is accessible in the subtree by calling just AuthProviderScope.of(context)
.
Open routes.dart
and udpate redirect callback as follow:
import 'package:passwordless_signin/auth_provider_scope.dart';
...
class AppRouter {
AppRouter._();
static final router = GoRouter(
...
redirect: (context, state) {
// 1
final authNotifierProvider = AuthProviderScope.of(context);
final isSignedIn = authNotifierProvider.isSignedIn;
...
// 2
if (isSignedIn == false) {
return isInSigninFlow ? null : Routes.emailForm.path;
}
// 3
else if (isInSigninFlow) {
return Routes.home.path;
} else {
return null;
}
},
);
}
- When notifier of
AuthProviderScope
sends change notification by callingupdateListeners
, the redirect callback will be triggered. - If user is in sign-in flow, do nothing otherwise redirect to signin flow entry point.
- If user is signed in and is in the sign-in flow, redirect to home page, otherwise no redirect needed.
Now when user opens the app by tapping on the sign-in link, user should get signed in and home pages should be displayed. Let’s verify:
Nice, it works, user is signed in and the good thing is we have covered all of the business requirements. 👏👏👏
Improve the UX
But there in one UX issue: while Firebase is signing the user, a loading indicator should be displayed. The loading page should be an standalone page, as user may navigate to different pages of the app and then tries to open the app by tapping on the sign-in link. hence we can’t assume to display loading indicator in theEmailSentPage
.
We need to introduce a new route to be displayed when sign-in link verification is in progress:
But before adding the new route lets add logout functionality to the app so user can logout:
Add sign out
Add sign out method to the PasswordlessAuthenticator
:
Future<void> signout() => _firebaseAuth.signOut();
Then update HomePage
with:
...
class HomePage extends StatelessWidget {
final VoidCallback onSignoutRequest;
const HomePage({super.key, required this.onSignoutRequest});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
backgroundColor: Colors.transparent,
actions: [TextButton(onPressed: onSignoutRequest, child: const Text('Signout'))],
),
body: Center(
...
),
);
}
}
Open routes.dart
and update the home route with:
GoRoute(
path: Routes.home.path,
builder: (context, state) => HomePage(
onSignoutRequest: () => getIt<PasswordlessAuthenticator>().signout(),
),
),
And try sign out:
Nice 👌.
Using closure enabled us to have pure UI, by moving sign out responsibility to upper layers.
Display loading indicator while sign-in is in progress
Start by creating a new page named SigninVerificationPage
, it just displays a simple loading indicator, nothing more.
import 'package:flutter/material.dart';
class SigninVerificationPage extends StatelessWidget {
const SigninVerificationPage({super.key});
@override
Widget build(BuildContext context) {
return Material(
child: Center(
child: Transform.scale(
scale: 1.5,
child: const CircularProgressIndicator.adaptive(),
),
),
);
}
}
Update routes with:
enum Routes {
...
signinVerification,
String get path {
switch (this) {
...
case Routes.signinVerification:
return '/signinVerification';
}
}
}
class AppRouter {
...
static final router = GoRouter(
routes: [
...
GoRoute(
name: Routes.signinVerification.name,
path: Routes.signinVerification.path,
builder: (context, state) => const SigninVerificationPage(),
),
],
);
}
Now we need to update redirect method to show loading page if a sign-in is in progress. update PasswordlessAuthenticator
with:
...
class PasswordlessAuthenticator {
...
final StreamController<bool> _loadingController =
StreamController.broadcast();
Stream<bool> get isLoading => _loadingController.stream;
...
Future<void> checkEmailLink() async {
_firebaseDynamicLinks.onLink.listen(
(event) async {
_setLoading(true);
final result = await signInWithEmailLink(event.link);
result.fold((failure) => onSigninFailure?.call(failure), (_) => null);
_setLoading(false);
},
onError: _handleLinkError,
);
try {
_setLoading(true);
final linkData = await _firebaseDynamicLinks.getInitialLink();
final link = linkData?.link.toString();
if (link != null) {
await signInWithEmailLink(linkData!.link);
}
...
} catch (e) {
...
} finally {
_setLoading(false);
}
}
void _setLoading(bool loading) {
_loadingController.add(loading);
}
void dispose() {
_loadingController.close();
}
}
We have added an StreamController
and then while email verification is in progress in checkEmailLink
, we add a new bool value to the loading stream so the listeners can be notified of loading update. It's a good practice to close the StreamController
when not needed. dispose will not be called automatically as dart objects don’t have a deinit
method. We need to call dispose method manually, but for our case we don’t use it now.
Also we should wait for sign in to be completed otherwise finally
part of try/catch
will be called immediately.
await signInWithEmailLink(linkData!.link);
Open auth_provider_scope.dart
and update AuthNotifier
accordingly:
...
class AuthNotifier extends ChangeNotifier {
...
bool isSigninInProgress = false;
StreamSubscription? _isLoadingSubscription;
AuthNotifier(
this._auth,
) {
...
_isLoadingSubscription = _auth.isLoading.listen((newValue) {
if (newValue != isSigninInProgress) {
isSigninInProgress = newValue;
notifyListeners();
}
});
}
@override
void dispose() {
_isLoadingSubscription?.cancel();
super.dispose();
}
}
The code here is simple when isLoading
changes, we notify listeners.
Then update redirect property of router:
redirect: (context, state) {
...
final isSigninInProgress = authNotifierProvider.isSigninInProgress;
if (isSigninInProgress) {
return Routes.signinVerification.path;
} else if (isSignedIn == false) {
// if user is in signin flow, do nothing otherwise redirect to signin flow entry point
return isInSigninFlow ? null : Routes.emailForm.path;
} else if (isSignedIn && (isInSigninFlow ||
state.matchedLocation == Routes.signinVerification.path)) {
// if user is signed in and is in signin flow or loading page,
// redirect to home page
return Routes.home.path;
} else {
// no need to redirect at all
return null;
}
},
Here when a sign in is in progress, we display SigninVerificationPage
.
Run the app, request a sign-in link and try to sign-in with that. although code is right, but SigninVerificationPage
is not shown! let’s debug.
As you see from below sequence diagram by the time app launches and we update loading stream, AuthProviderScope
has not been created. so loading will not be shown. one solution is call checkEmail
after AuthProviderScope
has been created. but its a naive solution and we can’t guarantee our behavior by calling methods of independent objects in an special sequence.
So whats the solution? Is there any way that when we start listening to the isLoading
stream, to be able to get the last emitted value? in case here the latest value would be true
, so we can display the loading page. Unfortunately when we start listening to the StreamController
stream, we can only receive the new values.
RxDart to the rescue 🛟
In RxDart
, BehaviorSubject
is same as StreamController
with the difference that when we start listening to that, it immediately passes the last value to us.
Update dependencies:
dependencies:
rxdart: ^0.27.7
then update loadingController in PasswordlessAuthenticator
to:
final BehaviorSubject<bool> _loadingController =
BehaviorSubject();
Lets test again, run the app and try to sign-in with another link:
Nice, it works as expected 👏👏👏
Display error alert in case of sign-in failure
What if sign in fails, how we should inform user? who should take responsibility of displaying error alert? the right candidate is SigninVerificationPage
as its responsibility is to display loading indicator and now sign-in error alert.
open AuthNotifier
and update with below code:
...
import 'package:passwordless_signin/auth/models/failure.dart';
class AuthNotifier extends ChangeNotifier {
...
// 1
ValueChanged<EmailLinkFailure>? onError;
bool _alertIsPresented = false;
set alertIsPresented(bool value) {
// 2
_alertIsPresented = value;
notifyListeners();
}
bool get alertIsPresented {
return _alertIsPresented;
}
StreamSubscription? _isLoadingSubscription;
AuthNotifier(
this._auth,
) {
...
// 3
_auth.onSigninFailure = (value){
onError?.call(value);
};
}
}
onError
property will be used bySigninVerificationPage
to dispaly an error alert.- Added a property named
alertIsPresented
to let to know listeners in our caserouter
to redirect to another route while an error alert is presented. - Although we can pass onError to
onSigninFailure
like_auth.onSigninFailure = onError
but that timeonError
isnull
and alert will not be shown. hence we need pass a closure to theonSigninFailure
.
Open SigninVerificationPage
and update build method with:
@override
Widget build(BuildContext context) {
final authNotifier = AuthProviderScope.of(context);
authNotifier.onError = (value) {
authNotifier.alertIsPresented = true;
showGenericDialog(
context: context,
title: 'Signin failure',
message: "Couldn't sign in with the link",
optionsBuilder: () => {'OK': true},
).then((value) => authNotifier.alertIsPresented = false);
};
...
}
Here in build method we listen to onError
property. When and error raised, we display an alert dialogue and let the notifier know that an alert is presented. and when dismissed, we set alertIsPresented
to false.
Open routes.dart
and update isSigninInProgress
with:
final isSigninInProgress = authNotifierProvider.isSigninInProgress ||
authNotifierProvider.alertIsPresented;
Open PasswordlessAuthenticator
and update check emailLink
method. we need to call onSigninFailure
when there is an error:
if (link != null) {
final result = await signInWithEmailLink(linkData!.link);
result.fold((failure) => onSigninFailure?.call(failure), (_) => null);
}
Now open the app with one of the previously used sign-in links, and you should see and error alert and while alert is presented the router doesn’t redirect to another route:
Great work 👏👏👏
To summerise:
- User can sign-in in the app by tapping on the link
- While a sign-in is in progress, a loading will be displayed
- In case of sign-in failure, an error alert is presented.
- Pages are decoupled from navigation. A router is responsible of making decision of navigation.
In order to test passwordless sign-in on iOS device you must have apple developer program. hence I have not covered it here.
You can view the code by checking sign-in-end
tag:
git checkout sign-in-end
Conclusion
We have covered all cases of sign-in success and failure paths, and updated UI accordingly. You did great work of following the tutorial until here, although it was lengthy but you learned nice practices and reasons behind them as you were following. The point is you don’t need to know everything in advance to start. You evolve the code as you face challenges. Just needed to find right thread and it will walk you through until you reach its end.
But how we can be sure the app components working well together?
In the next part we answer the above question:
Part 3 — Writing Unit Tests
See you there 😉