Taking the right route with Flutter.

Profile photo of Tair Rzayev
Tair Rzayev
Apr 02, 2020
4 min
Categories: Development
Road leading to the mountains
Road leading to the mountains

There is a lot of good documentation and articles about the navigation, Firebase dynamic links, and state management in Flutter. While all of them are clear and understandable in isolation, many developers may find themselves struggling to “put it all together” in the real-world application.

One of the problems with deep linking examples is that it’s not always as straightforward as pushing a specific route with the parameters, extracted from the deep link. For example, the deep link might only be available for users, who are logged in, in which case the user has to go through the login flow before opening the deeply linked widget.

I am sure a lot of you have noticed this by tapping on the link for some app, passing the login process only to realize the app has lost track of the link you have tapped and landed you on the home screen, making you tap the link again.

The problem

Route user to the specific screen in the app after they tap the app deep link. If any higher-priority steps such as authentication require taking care of — perform them, keeping in mind that the deep link has to be opened eventually.

Our toolbox

NB: It is assumed the reader is familiar with Flutter state management & specifically with the BLoC library. If you are not yet — I encourage you to do so- it’s really good stuff!

Let’s get to the code.

If you can’t wait to see what we end up with, here is the complete project.

If you want to go through this step-by-step in more detail, let’s get started!

Creating the app launcher.

Our app will always begin its life from the app launcher widget (a MaterialApp), which will push the required root widget, depending on the current app state. The flow we will be trying to achieve is this:

https://miro.medium.com/max/1400/1*eUSBgpkcBLbmOcQnXjUjBA.png

In real-world apps there might be extra onboarding steps aside from the login (accepting T&Cs, setting the username or photo, completing the onboarding tutorial) but we will stick just to the login flow to keep our example simple but keeping in mind that is has to be extendable for other use cases.

We will first define AppRoute class, which will represent possible routes the app can take (in our case either the home route or the details screen):

import 'package:equatable/equatable.dart'; abstract class AppRoute extends Equatable {} // Opened if there is no deep link passed to the app. class HomeRoute extends AppRoute { List<Object> get props => []; } class DetailsRoute extends AppRoute { final String itemId; DetailsRoute(this.itemId); List<Object> get props => [itemId]; }

Define the set of possible launch states, the app can be in:

import 'package:equatable/equatable.dart'; import 'app_route.dart'; abstract class LaunchState extends Equatable { const LaunchState(); List<Object> get props => []; } class AuthenticatedState extends LaunchState { // The route to open when the user is authenticated final AppRoute route; const AuthenticatedState(this.route); List<Object> get props => [route]; } class NotAuthenticatedState extends LaunchState {} class IsLoadingState extends LaunchState {}

We will also define AuthRepository. Authentication details are likely to differ from app to app and are not relevant to this article so let’s only focus on its interface:

abstract class AuthRepository { Future<void> login(String username, String password); Future<bool> isLoggedIn(); Future<void> logout(); }

Let’s now build an AppLaunchBloc :

import 'package:deeplinking/model/app_route.dart'; import 'package:deeplinking/model/launch_state.dart'; import 'package:deeplinking/repository/auth_repository.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:quiver/core.dart'; /// Events abstract class AppLaunchEvent extends Equatable { const AppLaunchEvent(); } class CheckLoginStatus extends AppLaunchEvent { final Uri deepLink; const CheckLoginStatus(this.deepLink); List<Object> get props => [deepLink]; } class RefreshLoginStatus extends AppLaunchEvent { List<Object> get props => []; } /// State class AppLaunchState extends Equatable { // The deep link is kept around until it can be used (user is authenticated). final Optional<Uri> deepLink; final LaunchState launchState; AppLaunchState(this.deepLink, this.launchState); AppLaunchState copyWith({Optional<Uri> deepLink, LaunchState state}) => AppLaunchState( deepLink ?? this.deepLink, state ?? this.launchState, ); List<Object> get props => [deepLink, launchState]; } /// BloC class AppLaunchBloc extends Bloc<AppLaunchEvent, AppLaunchState> { final AuthRepository authRepository; AppLaunchBloc(this.authRepository); AppLaunchState get initialState => AppLaunchState(Optional.absent(), IsLoadingState()); Stream<AppLaunchState> mapEventToState(AppLaunchEvent event) async* { if (event is CheckLoginStatus) { yield* _mapCheckLoginStatusToState(event); } else if (event is RefreshLoginStatus) { yield* _mapRefreshLoginStatusToState(event); } } Stream<AppLaunchState> _mapCheckLoginStatusToState( CheckLoginStatus event) async* { // Save the deep link into state and keep it around until we can use it. yield state.copyWith( deepLink: Optional.fromNullable(event.deepLink), state: IsLoadingState()); // Refresh the launch state, which will emit the appropriate AppLaunchState. yield* _refreshLaunchState(); } Stream<AppLaunchState> _mapRefreshLoginStatusToState( RefreshLoginStatus event) async* { yield state.copyWith(state: IsLoadingState()); yield* _refreshLaunchState(); } Stream<AppLaunchState> _refreshLaunchState() async* { final isLoggedIn = await authRepository.isLoggedIn(); if (isLoggedIn) { // User is logged in - parse the deep link & emit the appropriate state. final route = _getRoute(state.deepLink); yield state.copyWith( deepLink: Optional.absent(), state: AuthenticatedState(route)); } else { // User is not logged in - do not clear the deep link from the state (we // will use it after user is logged in) and emit the NotAuthenticatedState. yield state.copyWith(state: NotAuthenticatedState()); } } /// Parse the deep link into the corresponding [AppRoute] object. AppRoute _getRoute(Optional<Uri> deepLink) { if (deepLink.isEmpty) { return HomeRoute(); } final uri = deepLink.first; if (uri.path == "/details") { final id = uri.queryParameters["id"]; // Make sure the ID is in place to be more robust against invalid deep links. if (id != null) { return DetailsRoute(id); } } // Default to the home route. return HomeRoute(); } }

This BLoC will either accept CheckLoginStatus event, which will set the optional deep link and refresh the launch state or RefreshLoginStatus event, which will simply refresh the launch state. It is also important to note that we are keeping our deep link around in AppLaunchState.deepLin field until we end up in the state, in which we can open it (which is AuthenticatedState for this app).

What we need now is the actual UI widget, which will submit these events to the AppLaunchBloc and react to its state changes by pushing the required widget, which brings us to the AppLauncher:

import 'package:deeplinking/bloc/app_launch_bloc.dart'; import 'package:deeplinking/model/app_route.dart'; import 'package:deeplinking/model/app_routes.dart'; import 'package:deeplinking/model/launch_state.dart'; import 'package:deeplinking/widget/auth_page.dart'; import 'package:deeplinking/widget/details_page.dart'; import 'package:deeplinking/widget/home_page.dart'; import 'package:firebase_dynamic_links/firebase_dynamic_links.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AppLauncher extends StatefulWidget { State createState() => AppLauncherState(); static Widget withBloc() => BlocProvider( create: (context) => AppLaunchBloc(context.repository()), child: AppLauncher(), ); } class AppLauncherState extends State<AppLauncher> { final _navKey = GlobalKey<NavigatorState>(); AppLaunchBloc _bloc; void initState() { super.initState(); _bloc = context.bloc(); _initDynamicLinks(); } Widget build(BuildContext context) => BlocListener<AppLaunchBloc, AppLaunchState>( listener: (context, state) { // We cannot use Navigator.of(context) because the MaterialApp is a child // of this BlocListener, hence is below it in the widget hierarchy. // _navKey to the rescue! final navigatorState = _navKey.currentState; final launchState = state.launchState; if (launchState is AuthenticatedState) { final route = launchState.route; if (route is HomeRoute) { navigatorState.pushReplacementNamed(AppRoutes.home); } else if (route is DetailsRoute) { // Replace the current route with HomePage and push the DetailsPage // on top of that. navigatorState.pushReplacementNamed(AppRoutes.home); navigatorState.push(MaterialPageRoute( builder: (context) => DetailsPage(route.itemId))); } } else if (launchState is NotAuthenticatedState) { navigatorState.pushReplacementNamed(AppRoutes.login); } }, child: _buildApp(context), ); Widget _buildApp(BuildContext context) => MaterialApp( navigatorKey: _navKey, theme: ThemeData( primarySwatch: Colors.blue, ), routes: <String, WidgetBuilder>{ // Launched for the first time, on a cold app launch. AppRoutes.root: (context) => Container(), // Launched by the app (e.g. after the deep link was clicked) or to // re-route to the deep link after the user had logged in. AppRoutes.launcher: (context) { _bloc.add(RefreshLoginStatus()); return Container(); }, AppRoutes.home: (context) => HomePage(), AppRoutes.login: (context) => AuthPage.withBloc(), }, ); void _initDynamicLinks() async { final PendingDynamicLinkData data = await FirebaseDynamicLinks.instance.getInitialLink(); final Uri deepLink = data?.link; // Link was opened while the app was not open. _bloc.add(CheckLoginStatus(deepLink)); // Listen for deep links, opened while the app is running. FirebaseDynamicLinks.instance.onLink( onSuccess: (PendingDynamicLinkData dynamicLink) async { final Uri deepLink = dynamicLink?.link; if (deepLink != null) { // Link was opened while the app was open. _bloc.add(CheckLoginStatus(deepLink)); } }, onError: (OnLinkErrorException e) async { debugPrint("Dynamic link error $e"); }); } }

That snippet interspersed with a few comments, which hopefully make it more clear.

So far so good, we now have an AppLauncher widget, which pushes the required route, depending on the state, emitted by the AppLaunchBloc.

If user is not authenticated, AuthPage widget is pushed, which looks like this:

import 'package:deeplinking/bloc/auth_bloc.dart'; import 'package:deeplinking/model/app_routes.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class AuthPage extends StatelessWidget { static Widget withBloc() => BlocProvider( create: (context) => AuthBloc(context.repository()), child: AuthPage(), ); Widget build(BuildContext context) => Scaffold( body: BlocConsumer<AuthBloc, AuthState>( listener: (context, state) { if (state is SignedIn) { // Open the app launcher page, which will re-check the current // app state and launch the most appropriate widget. Navigator.of(context).pushNamedAndRemoveUntil( AppRoutes.launcher, (Route<dynamic> route) => false); } }, builder: (context, state) => Center( child: (state is InProgress || state is SignedIn) ? CircularProgressIndicator() : FlatButton( child: Text("Log me in!"), color: Colors.lightBlue, onPressed: () { BlocProvider.of<AuthBloc>(context) .add(SignInEvent("myUserName", "myPassword")); }, ), )), ); }

Its BLoC is omitted as it’s not relevant to the point we are about to discuss. What’s important is that after user signs in, we effectively remove any widgets until the root and then open the launcher after user signs in:

Navigator.of(context).pushNamedAndRemoveUntil( AppRoutes.launcher, (Route<dynamic> route) => false);

Basically, we are going back to AppLauncher , asking it to refresh the app state (which will now be logged in), which will make UI open the appropriate widget (either HomePage or DetailsPage for the deep link).

One could argue that we could launch the HomePage directly from the AuthPage . We might as well pass the deep link to the AuthPage and handle it there too. This is certainly an option but brings a couple of problems:

  • The routing responsibility will be “smeared” across multiple widgets ( AppLauncher and AuthPage ), which is harder to keep track of and maintain.
  • Remember that this is a “toy” example with only login flow. As you add more pages (optional username set up after login, agreeing to T&Cs, onboarding video or animation), things get more complicated. Each page will have to “decide” where to go next & the routing functionality will get even more spread out across multiple widgets.

Conclusion

First of all, congratulations on getting that far into this article!

We have discussed one of the ways to handle the app deep links, making sure it persists through the login flow or potentially any other flow the user may have to go through on the first app launch. I encourage you to clone and play with the complete source of the sample app for this article

This is definitely not the only way to handle this so if you have a better idea or found a bug/issue in the article or the code — please, put that down in the comment :).

Share with friends

Let’s build products together!

Contact us