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:

  1. Future<PendingDynamicLinkData?> getInitialLink()
    Attempts to retrieve the dynamic link which launched the app.
  2. 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()));
}

}
  1. checkEmailLink wraps boths onLink and getInitialLink to get any available sign_in link.
  2. Listen to any dynamic link while the app is active then passes the dynamic link to signInWithEmailLink method.
  3. getInitialLink gets the link when app is launched from not running state . if there is a link, it passes to signInWithEmailLink to handle sign-in
  4. 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 from FirebaseDynamicLinks .

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;
}
},
);
}
  1. When notifier of AuthProviderScope sends change notification by calling updateListeners, the redirect callback will be triggered.
  2. If user is in sign-in flow, do nothing otherwise redirect to signin flow entry point.
  3. 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:

Sign in

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:

Routing Guard

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 HomePagewith:


...

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:

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:

Display loading while sign-in is in progress

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);
};
}
}
  1. onError property will be used by SigninVerificationPage to dispaly an error alert.
  2. Added a property named alertIsPresented to let to know listeners in our case router to redirect to another route while an error alert is presented.
  3. Although we can pass onError to onSigninFailure like _auth.onSigninFailure = onError but that time onError is null and alert will not be shown. hence we need pass a closure to the onSigninFailure.

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:

Display error alert

Great work 👏👏👏

To summerise:

  1. User can sign-in in the app by tapping on the link
  2. While a sign-in is in progress, a loading will be displayed
  3. In case of sign-in failure, an error alert is presented.
  4. 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 😉

--

--