Reactive redirection using go_router and flutter_bloc with Auth and UnAuth Flow
Asked Answered
C

4

8

Implementing navigation based on auth changes using flutter_bloc and go_router.

What i need to do is at start if User is authenticated it should be redirected to HomeScreen with the ability to navigate through the auth flow...(HomeScreen,TestScreen1,TestScreen2)

If User is unAunthenticated at any point it should be redirected to LoginScreen and if Unknown then to Splash Screen.

I have gone through many stackoverflow questions and github issues but none of them provides a best pratice of how could we acheive this.

Problems:

  • Cannot navigate it in the auth flow, HomeScreen -> Test1 || HomeScreen -> Test2

Gives the following assetion:

Tried to listen to a value exposed with provider, from outside of the widget tree.

This is likely caused by an event handler (like a button's onPressed) that called
Provider.of without passing `listen: false`.

To fix, write:
Provider.of<$T>(context, listen: false);

It is unsupported because may pointlessly rebuild the widget associated to the
event handler, when the widget tree doesn't care about the value.

The context used was: $context
  • When working with flutter_web, if I'm on Home Screen (/auth/home_screen) and change the URL manually to /login_screen it throws an exception:
======== Exception caught by foundation library ====================================================
The following assertion was thrown while dispatching notifications for GoRouteInformationProvider:
Tried to listen to a value exposed with provider, from outside of the widget tree.

This is likely caused by an event handler (like a button's onPressed) that called
Provider.of without passing `listen: false`.

Changing to context.read instead of watch is not what i'm really into, because then i cannot listen to changes from the bloc.

What i have tried soo far is:

My AppRouter:

class AppRouter {
  GoRouter get router => _goRouter;

  late final GoRouter _goRouter = GoRouter(
    routes: <GoRoute>[
      GoRoute(
        path: '/',
        name: 'SPLASH',
        builder: (context, state) => const SplashScreen(),
      ),
      GoRoute(
        path: '/auth',
        name: 'AUTH',
        builder: (context, state) => HomeScreen(),
        routes: <GoRoute>[
          GoRoute(
            path: 'home_screen',
            name: 'HOME',
            builder: (context, state) => HomeScreen(),
          ),
          GoRoute(
            path: 'test_screen_1',
            name: 'TEST_SCREEN_1',
            builder: (context, state) => TESTSCREEN1(),
          ),
          GoRoute(
            path: 'test_screen_2',
            name: 'TEST_SCREEN_2',
            builder: (context, state) => TESTSCREEN2(),
          ),
        ],
      ),
      GoRoute(
        path: '/login_screen',
        name: 'LOGIN',
        builder: (context, state) => LoginScreen(),
      ),
    ],
    redirect: (context, state) {
      final authState = context.watch<AuthBloc>().state;
      final loginLocation = state.namedLocation('LOGIN');
      final authFlow = state.namedLocation('AUTH');
      final splashLocation = state.namedLocation('SPLASH');

      if (authState is AuthStateAuthenticated) {
        return authFlow;
      } else if (authState is AuthStateUnAuthenticated) {
        return loginLocation;
      } else {
        return splashLocation;
      }
    },
  );
}

App:

class App extends StatelessWidget {

  AppRouter appRouter = AppRouter();
  @override
  Widget build(BuildContext context) {
    return RepositoryProvider<AuthService>(
      create: (context) => AuthService(),
      child: MultiBlocProvider(
        providers: [
          BlocProvider<AuthBloc>(create: (context) => AuthBloc(authService:    RepositoryProvider.of<AuthService>(context))),
                  ],
        child: MaterialApp.router(
          routerDelegate: appRouter.router.routerDelegate,
          routeInformationParser: appRouter.router.routeInformationParser,
          routeInformationProvider: appRouter.router.routeInformationProvider,
        ),
      ),
    );
  }
}

Cycloid answered 13/5, 2023 at 2:5 Comment(5)
any luck fixing this?Komsomol
No, because of the lack of docs I switched to auto_route temporarilyCycloid
I was actually able to fix it in my cas and it was due to calling provider without listen:falsewhen getting the auth state. So, instead of Provider.of<Auth>(context) I needed to call Provider.of<Auth>(context, listen: false). In your case, it seems due to this: context.watch<AuthBloc>().stateKomsomol
How are able to listen to the auth changes, what if the user gets unauthenticated while using the application at any point? I know redirect is called on every navigation try. But navigating inside the Auth flow i.e /auth/home , /auth/home/test1 , /auth/home/test2, still remains a valid problem in my case.Cycloid
since this (the redirect method) is called before accessing any route, if the user gets unauthenticated, this will be checked and the user will be redirectedKomsomol
W
9

I know that my answer is a bit late. But I thought that this might be of concern to anyone who may run into this issue and needs a proper solution.

Indeed, you can't do final authState = context.watch(); inside redirect. You don't want to trigger a widget rebuild from outside a widget!! Below is a better and cleaner approach.

  • Listen to the AuthenticationState.status change at the MaterialApp() level. In other words, wrap it with a BlocListener() widget.
  • Inside the listener callback, you should trigger the GoRouter.refresh(). You can add some other goodies here such as conditional listening and other checks to prevent useless rebuilds ;)
  • Lastly, inside the GoRouter's redirect callback, you read the current AuthenticationState.staus value.

Below is a complete working example. In this example, I am assuming that you are working with the flutter_login example as provided by the official repo. We will only change the AppView() widget and add a new file appRouter.dart both of these changes are described below.

  1. Create an abstract class AppRouter to encapsulate our router's logic and define a static router instance:
// we are inside appRouter.dart
//. a bunch of imports...
abstract class AppRouter {
  static GoRouter router = GoRouter(
    routes: [
      // A handy shortcut to encapsulate all route-specific concerns in their respective owners. In
      // Inside the HomePage() widget, for instance, this is defined as
      // 
      // static String get pagePath => '/';
      // static MaterialPage<void> _materialPage(Key key) => MaterialPage(
      //       child: HomePage(key: key),
      //     );
      // static GoRoute get route => GoRoute(
      //       path: pagePath,
      //       pageBuilder: (context, state) => _materialPage(state.pageKey),
      //     );

      HomePage.route, 
      LoginPage.route,
    ],
    errorPageBuilder: (context, state) => const MaterialPage(
      child: Scaffold(
        body: Center(
          child: Text('Route not found!'),
        ),
      ),
    ),
    redirect: (context, state) async {
      // Here we need to read the context `context.read()` and decide what to do with its new values. we don't want to trigger any new rebuild through `context.watch`
      final status = context.read<AuthenticationBloc>().state.status;
      if (status == AuthenticationStatus.authenticated) {
        return null;
      }
      return '/login';
    },
  );
}

  1. Update the AppView() widget to make it compatible with the go_router package and listen for AuthenticationState.status changes:
class AppView extends StatefulWidget {
  const AppView({super.key});

  @override
  State<AppView> createState() => _AppViewState();
}

class _AppViewState extends State<AppView> {
  @override
  Widget build(BuildContext context) {
    return BlocListener<AuthenticationBloc, AuthenticationState>(
      listener: (context, state) {
        // We are refreshing the sole instance of `GoRouter`
        AppRouter.router.refresh();
      },
      // Here we need the MaterialApp.router() to use our sole GoRouter instance.
      child: MaterialApp.router(
        debugShowCheckedModeBanner: false,
        routeInformationParser: AppRouter.router.routeInformationParser,
        routerDelegate: AppRouter.router.routerDelegate,
        routeInformationProvider: AppRouter.router.routeInformationProvider,
      ),
    );
  }
}

Now you should have a beautiful, and clean symphony between flutter_bloc and go_router. where It's one is taking care of its own business.

Waltner answered 13/12, 2023 at 5:37 Comment(1)
Thank you for the answer :) , I had a slightly different use case, since I'm using Riverpod, but watching for an update that GoRouter can't detect and using GoRouter.refresh() to re-trigger redirects is working perfectlyEntertainment
A
2

I'm using flutter_bloc and get_it for dependency injection, and was also struggling with this topic (mainly due to lack of proper documentation).

Eventually I based my solution on this example from the official docs.

Step 1: in your auth_bloc.dart file add AuthStreamScope and AuthStream classes.
Note: in the code below, replace getIt.get<AuthBloc>() with your auth bloc instance:

class AuthStreamScope extends InheritedNotifier<AuthStream> {
  AuthStreamScope({super.key, required super.child}) : super(notifier: AuthStream(getIt.get<AuthBloc>().stream));
  static AuthStream of(BuildContext ctx) => ctx.dependOnInheritedWidgetOfExactType<AuthStreamScope>()!.notifier!;
}

class AuthStream extends ChangeNotifier {
  late final StreamSubscription<dynamic> _subscription;

  AuthStream(Stream<dynamic> stream) {
    notifyListeners();
    _subscription = stream.asBroadcastStream().listen((_) => notifyListeners());
  }

  bool isSignedIn() => getIt.get<AuthBloc>().state.user != null;

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }
}

Step 2: wrap your app with the AuthStreamScope:

// from with your app's build method:
return AuthStreamScope(
    child: MaterialApp.router(
      theme: theme,
      darkTheme: darkTheme,
      routerConfig: _routerInstance, // your GoRouter
    ),
  ),
);

Step 3: in your GoRouter initialization, listen to auth changes as follows.

GoRouter(
  redirect: (BuildContext context, GoRouterState state) async {
    // calling `of` method creates a dependency of AuthStreamScope. It will make go_router to reparse
    // current route if AuthStream has new sign-in information.
    final bool loggedIn = AuthStreamScope.of(context).isSignedIn();

    // implement your redirection logic here

    return null; // no need to redirect
  },

  // routes, etc.
)

So essentially GoRouter is listening to any AuthBloc stream updates and redirects accordingly. Auth in particular, unlike other blocs can be defined as (lazy) singletons, so GetIt comes very handy as shown in the code above.

I hope it helps.

Atlante answered 18/9, 2023 at 22:14 Comment(2)
Hi, Correct me if I make some mistakes in implementing my code.. I still have to add a blocprovider at the top of my widget tree.Exsert
Hi, I've tried your solution and it doesn't solve the issue. If you can have a look at my code here - #78171145Fainthearted
I
1

I have been working through the exact same issue. In researching possible solutions, I came up with 3 different approaches (all of which work) and which are documented in detail here: How to handle authentication in Flutter with go_router and Firebase Authentication when already on a route?

Though not using flutter_bloc at the moment, Option 1 in the above, gives insight into how to solve the problem you're describing using a Provider model, which would be easily translated to a BLoC model.

My conclusion however was that there are easier ways to do what you're looking for without using Provider or BLoC, see options 2 & 3 in the above. In summary...

TL;DR: Option 3 (see above link) is my preferred choice, which uses the GoRouter.refresh() method at the main() level to dynamically update the GoRouter state based on events from the auth stream.

Using this approach you can simply read the variables/properties (context.read) you need for your routing logic in your GoRouter definition while router.refresh() at the main() level takes care of refreshing the routes each time the auth state changes.

Ilana answered 9/11, 2023 at 12:40 Comment(0)
L
1

Finally, I've found a solution to this problem without utilizing the redirect and refreshListenable properties of GoRouter.

The concept involves creating a custom redirection component. Essentially, authStateChanges are globbally listened, and upon any change in AuthenticationState, we navigate to our redirection component. In my implementation, this redirection component is a splash page. From there, you can proceed to redirect to the desired page.

Here is my code

Router Class

class AppRouter {
factory AppRouter() => _instance;

AppRouter._();
static final AppRouter _instance = AppRouter._();
final _config = GoRouter(
initialLocation: RoutePaths.splashPath,
routes: <RouteBase>[
  GoRoute(
    path: RoutePaths.splashPath,
    builder: (context, state) => SplashPage(
      currentPath: Uri.base.path,
    ),
  )
  GoRoute(
    path: RoutePaths.loginPath,
    builder: (context, state) => const LoginPage(),
  ),
  GoRoute(
    path: RoutePaths.registrationPath,
    builder: (context, state) => const RegistrationPage(),
  ),
  GoRoute(
    path: RoutePaths.homePath,
    builder: (context, state) => const HomePage(),
  ),
 ],
);
GoRouter get config => _config;
}

class RoutePaths {
static const String loginPath = '/login';
static const String registrationPath = '/register';
static const String homePath = '/home';
static const String splashPath = '/';
}

Authentication Bloc

AuthenticationBloc(
this.registerUseCase,
this.loginUseCase,
) : super(const AuthenticationInitial()) {
on<AuthenticationStatusCheck>((event, emit) async {
  await emit.onEach(
    FirebaseAuth.instance.authStateChanges(),
    onData: (user) async {
      this.user = user;
      emit(const AuthenticationSplash());
      await Future.delayed(const Duration(seconds: 1, 
      milliseconds: 500),
          () {
        if (user == null) {
          emit(const AuthenticationInitial());
        } else if (state is! AuthenticationSuccess) {
          emit(AuthenticationSuccess(user));
        }
      });
    },
  );
});

Main

final router = AppRouter().config;
return MultiBlocProvider(
  providers: [
    BlocProvider<AuthenticationBloc>(
      create: (context) =>
          sl<AuthenticationBloc>()..add(const AuthenticationStatusCheck()),
    ),
  ],
  child: BlocListener<AuthenticationBloc, AuthenticationState>(
    listener: (context, state) {
      if (state is AuthenticationSplash) {
        router.go(RoutePaths.splashPath);
      }
    },
    child: MaterialApp.router(
      title: 'Hero Games Case Study',
      theme: ThemeData.dark(
        useMaterial3: true,
      ),
      routerConfig: router,
    ),
  ),
);

splash_page

return BlocListener<AuthenticationBloc, AuthenticationState>(
  listener: (context, state) {
    if (state is AuthenticationSuccess) {
      context.go(
        currentPath == RoutePaths.splashPath ||
                currentPath == RoutePaths.loginPath ||
                currentPath == RoutePaths.registrationPath
            ? RoutePaths.homePath
            : currentPath,
      );
    } else if (state is AuthenticationInitial) {
      context.go(RoutePaths.loginPath);
    }
  },
  child: Scaffold(
    appBar: AppBar(
      title: const Text('Splash Page'),
    ),
    body: const Center(
      child: CircularProgressIndicator(),
    ),
  ),
);

NOTE:

If you will use deeplinking, you can add the code below to your GoRouter

redirect: (context, state) {
  if (context.read<AuthenticationBloc>().user == null &&
      (state.fullPath != RoutePaths.loginPath &&
          state.fullPath != RoutePaths.registrationPath &&
          state.fullPath != RoutePaths.splashPath)) {
    return RoutePaths.splashPath;
  }
  return null;
},
Lento answered 31/3 at 3:36 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.