felangel / bloc

A predictable state management library that helps implement the BLoC design pattern
https://bloclibrary.dev
MIT License
11.73k stars 3.38k forks source link

[Question][go_router] Reactive redirection using go_router and bloc #3747

Closed petodavid closed 1 year ago

petodavid commented 1 year ago

What would be the best way to somehow implement a reactive redirection using bloc states? Currently I'm doing redirection like this:

router.dart
final router = GoRouter(
  routes: [
    GoRoute(
      name: OnBoardingPage.name,
      path: '/',
      builder: (context, state) => OnBoardingPage(),
      redirect: (context, state) async {

        final homeLocation = '/login';
        if (isMobilePlatform) {
          final onBoardingCubit = context.read<OnBoardingCubit>();
          await onBoardingCubit.init();
          if (onBoardingCubit.state is DoNotShowOnBoarding) return homeLocation;

          return null;
        }

        return homeLocation;
      },
    ),
    GoRoute(
      name: 'login',
      path: '/login',
      builder: (context, state) => HomePage(),
    ),
  ],
);
onboardingpage.dart
class OnBoardingPage extends StatelessWidget {
  static String name = 'oboarding';
  const OnBoardingPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocListener<OnBoardingCubit, OnBoardingState>(
      listener: (context, state) {
        if (state is DoNotShowOnBoarding) {
          GoRouter.of(context).refresh();
        }
      },
      child: IntroductionScreen(
        rawPages: introductionPages(context),
        onDone: () => context.read<OnBoardingCubit>()..onBoardingIsCompleted(),
        next: Icon(Icons.arrow_forward),
        done: Text('done'.tr()),
        curve: Curves.easeInQuad,
        controlsMargin: const EdgeInsets.all(16),
        dotsDecorator: DotsDecorator(
          size: const Size.square(10.0),
          activeSize: const Size(20.0, 10.0),
          activeColor: Theme.of(context).colorScheme.primary,
          spacing: const EdgeInsets.symmetric(horizontal: 3.0),
          activeShape: RoundedRectangleBorder(
            borderRadius: BorderRadius.circular(25.0),
          ),
        ),
      ),
    );
  }
}

However I do not like the fact hat I have to call GoRouter.of(context).refresh() in the bloc listener to trigger the redirection again. Is there a more professional way to work with flutter_bloc ?

tenhobi commented 1 year ago

If you want to control redirections using Bloc, I would do it somehow like this: (demo from my side project; not using go_router, but it should be a good reference)

https://github.com/kingkareldev/client/blob/main/packages/presentation/lib/router/blocs/router/router_bloc.dart https://github.com/kingkareldev/client/blob/main/packages/presentation/lib/router/app_router_delegate.dart#L225

However, using go_router you can just simply call push/go methods inside your widgets. Go router will manage the state for you. You don't have to use another layer above that.

Do some constants for your paths and routeInfos and it should be ok.

petodavid commented 1 year ago

@chunhtai said that "context.read() will register the dependency to the router, so that it will trigger redirect if that dependency changes. So this is exactly the right way to handle declarative redirect." And this sounds amazing, however I can not find a way to do it like that. I tried it like this but in this case if the dependency (Bloc state) changes the redirection is not triggered.

 GoRoute(
        name: LoginPage.name,
        path: '/login',
        builder: (context, state) => LoginPage(),
        redirect: (context, state) {
         final state =  context.read<AuthBloc>().state;
          if(state is Something) return '/something'
          if(state is SomethingElse) return '/somethingElse'
        },
    ),

My question is, am I completely misunderstanding something or should this work?

felangel commented 1 year ago

@chunhtai said that "context.read() will register the dependency to the router, so that it will trigger redirect if that dependency changes. So this is exactly the right way to handle declarative redirect." And this sounds amazing, however I can not find a way to do it like that. I tried it like this but in this case if the dependency (Bloc state) changes the redirection is not triggered.

 GoRoute(
        name: LoginPage.name,
        path: '/login',
        builder: (context, state) => LoginPage(),
        redirect: (context, state) {
         final state =  context.read<AuthBloc>().state;
          if(state is Something) return '/something'
          if(state is SomethingElse) return '/somethingElse'
        },
    ),

My question is, am I completely misunderstanding something or should this work?

context.read only reads the current state and doesn’t mark the widget as a dependent. I believe you’re thinking of context.watch instead.

petodavid commented 1 year ago

@felangel as you can see the redirect function is not a widget, it returns a FutureOr<String>, are you sure that we can use context.watch here to achieve route redirection? I also receive the following assertion in case of a navigation like context.goNamed()

======== 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`.

To fix, write:
Provider.of<TestCubit>(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: Router<Object>(dependencies: [UnmanagedRestorationScope, _InheritedProviderScope<OnBoardingCubit?>, _InheritedProviderScope<TestCubit?>], state: _RouterState<Object>#8ec86)
'package:provider/src/provider.dart':
Failed assertion: line 274 pos 7: 'context.owner!.debugBuilding ||
          listen == false ||
          debugIsInInheritedProviderUpdate'

Here are my router:

final router = GoRouter(
  navigatorKey: navigatorKey,
  routes: [
    GoRoute(
      name: OnBoardingPage.name,
      path: '/',
      builder: (context, state) => OnBoardingPage(),
      redirect: (context, state) {
        final redirectLocation = '/login';
        if (kIsWeb) return redirectLocation;
        final onBoardingCubit = context.watch<OnBoardingCubit>();
        final onBoardingCubitState = onBoardingCubit.state;
        if (onBoardingCubitState is DoNotShowOnBoarding) return redirectLocation;

        return null;
      },
    ),
    GoRoute(
      name: LoginPage.name,
      path: '/login',
      builder: (context, state) => LoginPage(),
    ),
    GoRoute(
      name: SignupPage.name,
      path: '/signup',
      builder: (context, state) => SignupPage(),
    ),
  ],
  redirect: (context, state) {
    final testCubit = context.watch<TestCubit>();
    final testCubitState = testCubit.state;
    if (testCubitState is TestLogin) return '/login';
    if (testCubitState is TestSignup) return '/signup';

    return null;
  },
);
Gene-Dana commented 1 year ago

What would be the best way to somehow implement a reactive redirection using bloc states? Currently I'm doing redirection like this:

Is flowbuilder considered here?

https://github.com/felangel/bloc/blob/b11fe6a1c00a5cb22fd38b2eba2ae6c0c773cec5/examples/flutter_firebase_login/lib/app/routes/routes.dart#L6-L16

chunhtai commented 1 year ago

Sorry I was wrong, using watch should be correct since it will add dependency.

The error looks like the watch is limited to build phase? context.owner!.debugBuilding. The entire parsing pipeline is in a async process. which would be called in between builds. Is that a reason it is limited?

petodavid commented 1 year ago

As far as I see we do not have best practices when it comes to bloc state based go_router redirection, please correct me if I am wrong

jopmiddelkamp commented 1 year ago

I used this in our project. Hope this may help you.

GoRouter buildRouter({
  required AuthCubit authCubit,
}) {
  return GoRouter(
    initialLocation: initialLocation,
    routes: kRoutes,
    refreshListenable: _buildRefreshListener(
      authCubit,
    ),
    redirect: (context, state) {
      // Handle auth redirection
      if (authCubit.state.data.isSignedIn ?? false) {
        if (state.location == '/auth' ||
            state.location == '/intro' ||
            state.location == '/register' ||
            state.location.startsWith('/register?') ||
            state.location == '/recovery') {
          return '/home';
        }
      } else {
        if (!state.location.startsWith('/register') &&
            !state.location.startsWith('/recovery') &&
            !state.location.startsWith('/intro') &&
            !state.location.startsWith('/auth')) {
          return '/auth';
        }
      }
      if (state.location == '/') {
        return '/home';
      }
      return null;
    },
    // debugLogDiagnostics: kDebugMode,
  );
}

StreamListener _buildRefreshListener(
  AuthCubit authCubit,
) {
  return StreamListener(
    authCubit.stream
        .startWith(authCubit.state)
        .map((e) => e.data.isSignedIn)
        .distinct(),
  );
}
felangel commented 1 year ago

Closing for now but happy to continue the conversation if you still have questions 👍

tahaaslam1 commented 1 year ago

As far as I see we do not have best practices when it comes to bloc state based go_router redirection, please correct me if I am wrong

Have you got one? what @jopmiddelkamp has proposed is refreshListenable This has been removed in GoRouter v5. Refer to the migration guide: flutter.dev/go/go-router-v5-breaking-changes

@felangel can we reopen the issue with someone proposing a better well-defined example, with go_router redirection based on state changes from Bloc, as done with auto_route here:

jopmiddelkamp commented 1 year ago

Yeah the StreamListener is more a component that belongs in the Flutter framework. Just not had the time/focus yet to figure out where to add it in the Flutter framework. But as temporary solution you can just add it in your own codebase.

_streamlistener.dart:

import 'dart:async';

import 'package:flutter/foundation.dart';

// TODO: Add to package:flutter/foundation.dart?
/// Converts a [Stream] into a [Listenable]
///
/// {@tool snippet}
/// Typical usage is as follows:
///
/// ```dart
/// StreamListener(stream)
/// ```
/// {@end-tool}
class StreamListener extends ChangeNotifier {
  /// Creates a [StreamListener].
  ///
  /// Every time the [Stream] receives an event this [ChangeNotifier] will
  /// notify its listeners.
  StreamListener(Stream<dynamic> stream) {
    notifyListeners();
    _subscription = stream.asBroadcastStream().listen((_) => notifyListeners());
  }

  late final StreamSubscription<dynamic> _subscription;

  @override
  void dispose() {
    _subscription.cancel();
    super.dispose();
  }
}
hey-cwuerzlhuber commented 1 year ago

Sorry I was wrong, using watch should be correct since it will add dependency.

The error looks like the watch is limited to build phase? context.owner!.debugBuilding. The entire parsing pipeline is in a async process. which would be called in between builds. Is that a reason it is limited?

Is there any answer to this question? IMO it would be the most comfortable way to just use bloc.watch() inside redirect of go_router.

Would appreciate any answer.

asafratzon commented 1 year ago

Sorry I was wrong, using watch should be correct since it will add dependency. The error looks like the watch is limited to build phase? context.owner!.debugBuilding. The entire parsing pipeline is in a async process. which would be called in between builds. Is that a reason it is limited?

Is there any answer to this question? IMO it would be the most comfortable way to just use bloc.watch() inside redirect of go_router.

Would appreciate any answer.

Hi @hey-cwuerzlhuber, I recently shared my solution for redirection with go_router and Bloc in stack overflow. Hope it helps!

53huzefa-zeenwala commented 10 months ago

can i have code of above issue answer.

dragos-triteanu commented 6 months ago

Hey here guys and thank you for the mental work that you have put into this thread. I am currently still facing issues in implementing a correct production-ready redirection strategy for both mobile and web using go_router and bloc.

In my scenario, I need to have an "AuthGuard" (term borrowed from Angular) that prevents users from accessing certain routes without being authenticated. I have the following use cases:

  1. If the user is on the web version of the app, then have a distinct router (WebAppRouter in my case)
  2. If the user tries to access a secure resource, such as '/users':
    • If authenticated, he will be taken to '/users'
    • If not authenticated, he will be taken to '/login'
  3. If the user is on the '/login' page and authenticates himself, he will be redirected to the '/users' page
  4. Upon refresh, if the user is authenticated, he should remain on the page that he currently is on. So if he is authenticated and is currently on the '/login' or '/whatever' path, then he should remain there without being redirected to '/users'.
  5. Upon successful access of the '/users' path, the app should load a Scaffold that has a Drawer with navigation items. The state of these items should reflect where the user is in the application.

From what I could read about the go_router, it seems that this can be achieved using the redirect property of the GoRouter as such:

class WebAppRouter {
  final _rootNavigatorKey = GlobalKey<NavigatorState>();
  final _shellNavigatorKey = GlobalKey<NavigatorState>();

  late final GoRouter router = GoRouter(
    debugLogDiagnostics: true,
    navigatorKey: _rootNavigatorKey,
    routes: [
      ShellRoute(
        navigatorKey: _shellNavigatorKey,
        builder: (context, state, child) {
          return CommonAppPage(child: child);
        },
        routes: [
          // These are the pages that are displayed under a web Scaffold
          ShellRoute(
              builder: (context, routerState, child) {
                return BlocConsumer<AuthBloc, AuthState>(
                  listener: (context, state) {
                    if (!state.authenticated) {
                      context.go(Routes.login.path);
                    }
                  },
                  builder: (context, authState) {
                    if (!authState.authenticated) {
                      return Center(
                        child: CircularProgressIndicator(),
                      );
                    }

                    return WebAppPage(
                      currentUser: authState.user!,
                      currentPath: routerState.fullPath,
                      child: child,
                    );
                  },
                );
              },
              routes: [
                GoRoute(
                    path: '/',
                    redirect: (context, state) {
                      return Routes.users.path;
                    }),
                GoRoute(
                    name: Routes.users.name,
                    path: Routes.users.path,
                    pageBuilder: (context, state) {
                      return CustomTransitionPage<void>(
                        key: state.pageKey,
                        child: UsersPage(),
                        transitionsBuilder: (context, animation, secondaryAnimation, child) =>
                            FadeTransition(opacity: animation, child: child),
                      );
                    }),
              ]),
          GoRoute(name: Routes.login.name, path: Routes.login.path, builder: (context, state) => LoginPage()),
        ],
      ),
    ],
    redirect: (context, state) {
      final isAuthenticated = context.read<AuthBloc>().state.authenticated;
      final isLoggingIn = state.fullPath == Routes.login.path;

      // User is not authenticated and not on the login page, redirect to login
      if (!isAuthenticated && !isLoggingIn) {
        return Routes.login.path;
        // User is authenticated and on the login page, redirect to the users page
      } else if (isAuthenticated && isLoggingIn) {
        return Routes.users.path;
      }
      // No redirection needed
      return null;
    },
  );
}

This is ok-ish in most cases. Now the thing that happens is that my go-router depends on the state from bloc, which is practically async. This means that the redirect bloc will not be called again if the user becomes authenticated or unauthenticated, unless I rebuild the entire router, each time the state changes. And this will cause redirection again to the default paths. If I don't do this, then I will suffer from erroneous redirection, as the state flag will update with delay.

My question to the community and mostly @felangel (who I respect deeply) is if you guys have a working example of a web app in flutter that handles authentication and correct redirection reactively?

Thank you so much for your time!

SyncroIT commented 3 months ago

The best solution I've found is to wrap the Widget returned from the build method on the router config, with another Widget that uses BlocConsumer to listen for changes and invoke go_router's navigation when needed.

router.dart

final router = GoRouter(
    routes: [
      GoRoute( 
        path: '/homepage',
        builder: (context, state) => RefreshOnUpdate(const HomePage()),
      ),
      GoRoute(
        path: '/auth',
        builder:(context, state) => const AuthPage()
      )
    ]
  );

RefreshOnUpdate.dart

class RefreshOnUpdate extends StatelessWidget {
  Widget child;

  RefreshOnUpdate(this.child, {super.key});

  @override
  Widget build(BuildContext context) {
    return BlocConsumer<UserCubit, UserState>(
      listener: (context, state) {
        // Redirect if the user is no longer authenticated
        if(state.user == null) {
          context.go("/auth");
        }
      },
      builder: (context, state) => child,
    );
  }
}

You can also use redirect on the route config, but that gets called whenever you load the page the first time, and not when the cubit updates its state.