7

I'm integrating GoRouter in my Flutter app where I'm already using Riverpod. I have an isAuthorizedProvider defined as follows:

final isAuthorizedProvider = Provider<bool>((ref) {
  final authStateChanged = ref.watch(_authStateChangedProvider);
  final user = authStateChanged.asData?.value;
  return user != null;
});

And I'm not sure how to define a GoRouter that depends on the Provider above. I've come up with the following:

final goRouterProvider = Provider<GoRouter>((ref) => GoRouter(
      debugLogDiagnostics: true,
      redirect: (state) {
        final isAuthorized = ref.watch(isAuthorizedProvider);
        final isSigningIn = state.subloc == state.namedLocation('sign_in');

        if (!isAuthorized) {
          return isSigningIn ? null : state.namedLocation('sign_in');
        }

        // if the user is logged in but still on the login page, send them to
        // the home page
        if (isSigningIn) return '/';

        // no need to redirect at all
        return null;
      },
      routes: [
        GoRoute(
          path: '/',
          ...,
        ),
        GoRoute(
          name: 'sign_in',
          path: '/sign_in',
          ...,
        ),
        GoRoute(
            name: 'main',
            path: '/main',
            ...,
        ),
        ...
      ],
    ));

class MyApp extends ConsumerWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final goRouter = ref.watch(goRouterProvider);
    return MaterialApp.router(
      routeInformationParser: goRouter.routeInformationParser,
      routerDelegate: goRouter.routerDelegate,
    );
  }

Is this the right way to do it?

Marco
  • 1,572
  • 1
  • 10
  • 21
  • seems right because i was getting a route at runtime and navigating to it works fine, so this should also work.(i am not totally sure because i was only changing my routes once). if this does not gives you compile time error then you should test extensively in runtime if you are not confidence :) – Ruchit Mar 01 '22 at 11:47

2 Answers2

2

I don't thing you should be calling this line

ref.watch(isAuthorizedProvider);

inside the redirect block, because that will cause your entire GoRouter instance to rebuild (and you'll lose the entire nav stack).

This is how I've done it:

class AppRouterListenable extends ChangeNotifier {
  AppRouterListenable({required this.authRepository}) {
    _authStateSubscription =
        authRepository.authStateChanges().listen((appUser) {
      _isLoggedIn = appUser != null;
      notifyListeners();
    });
  }
  final AuthRepository authRepository;
  late final StreamSubscription<AppUser?> _authStateSubscription;
  var _isLoggedIn = false;
  bool get isLoggedIn => _isLoggedIn;

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

final appRouterListenableProvider =
    ChangeNotifierProvider<AppRouterListenable>((ref) {
  final authRepository = ref.watch(authRepositoryProvider);
  return AppRouterListenable(authRepository: authRepository);
});

final goRouterProvider = Provider<GoRouter>((ref) {
  final authRepository = ref.watch(authRepositoryProvider);
  final appRouterListenable =
      AppRouterListenable(authRepository: authRepository);
  return GoRouter(
    debugLogDiagnostics: false,
    initialLocation: '/',
    redirect: (state) {
      if (appRouterListenable.isLoggedIn) {
        // on login complete, redirect to home
        if (state.location == '/signIn') {
          return '/';
        }
      } else {
        // on logout complete, redirect to home
        if (state.location == '/account') {
          return '/';
        }
        // TODO: Only allow admin pages if user is admin (#125)
        if (state.location.startsWith('/admin') ||
            state.location.startsWith('/orders')) {
          return '/';
        }
      }
      // disallow card payment screen if not on web
      if (!kIsWeb) {
        if (state.location == '/cart/checkout/card') {
          return '/cart/checkout';
        }
      }
      return null;
    },
    routes: [],
  );
}

Note that this code is not reactive in the sense that it will refresh the router when the authState changes. So in combination with this, you need to perform an explicit navigation event when you sign-in/sign-out.

Alternatively, you can use the refreshListenable argument.

bizz84
  • 1,964
  • 21
  • 34
  • I don't see how this would work with something like Firebase Authentication. Because the router is passive, not watching, the first call to something like `appRouterListenable.isLoggedIn` will be false during the period that the authentication system is doing its work to determine if you're actually logged in or not - it will go through a loading state, and then eventually emit the right value. So the above will just redirect to "/signIn" even if you are logged in. – Jon Mountjoy Jul 14 '22 at 17:05
  • It would be easy enough to modify the `AppRouterListenable` class to use an `enum AuthState { loading, notLoggedIn, loggedIn }` as state. That way the redirect callback has everything it needs to show a loading route if needed. – bizz84 Jul 15 '22 at 20:12
  • 1
    note for future readers: refreshListenable may not work as intended anymore: github.com/flutter/flutter/issues/116651. – elllot Feb 11 '23 at 07:08
2

You can do it this way using redirect, however I've come up with a way that uses navigatorBuilder. This way you maintain the original navigator state (you will be redirected back to whichever page you originally visited on web or with deep linking), and the whole router doesn't have to be constantly be rebuilt.

final routerProvider = Provider((ref) {
  return GoRouter(
    routes: [
      GoRoute(
        path: '/',
        builder: (context, state) => const OrdersScreen(),
      ),
      GoRoute(
        path: '/login',
        builder: (context, state) => const AuthScreen(),
      ),
    ],
    navigatorBuilder: (context, state, child) {
      return Consumer(
        builder: (_, ref, __) =>
          ref.watch(authControllerProvider).asData?.value == null
            ? Navigator(
                onGenerateRoute: (settings) => MaterialPageRoute(
                  builder: (context) => AuthScreen(),
                ),
              )
            : child,
      );
    },
  );
});

navigatorBuilder basically allows you to inject some widget between the MaterialApp and the Navigator. We use Riverpod's consumer widget to access the ref and then the whole router doesn't have to be rebuilt, and we can access auth state using the ref.

In my example, ref.watch(authControllerProvider) returns an AsyncValue<AuthUser?>, so if the user is logged in, we return the child (current navigated route), if they are logged out, show them login screen, and if they are loading we can show a loading screen etc.

If you want to redirect users based on roles (e.g. only admin can see admin dashboard), then that logic should go into the redirect function using a listenable as @bizz84 described.

HJo
  • 1,902
  • 1
  • 19
  • 30
  • 1
    on go router 5.0.0 they remove `navigatorBuilder`, i like your way to inject, any update on this method? – Sayyid J Oct 14 '22 at 05:04
  • 1
    @SayyidJ here's the go router 5 [migration guide](https://docs.google.com/document/d/10l22o4ml4Ss83UyzqUC8_xYOv_QjZEi80lJDNE4q7wM/edit?resourcekey=0-U-BXBQzNfkk4v241Ow-vZg#heading=h.p0dc6gtmbit4) where it says you can use the Material app builder instead :) – HJo Oct 17 '22 at 05:41