csells / go_router

The purpose of the go_router for Flutter is to use declarative routes to reduce complexity, regardless of the platform you're targeting (mobile, web, desktop), handling deep linking from Android, iOS and the web while still allowing an easy-to-use developer experience.
https://gorouter.dev
441 stars 96 forks source link

[Question] How to show loading screen on intermediate authstate #209

Closed kornha closed 2 years ago

kornha commented 2 years ago

Killer package, thanks so much once again. I wanted to run by an important use case here for myself, and I imagine for others, and hopefully get your input.

If I follow the sample, I get something like this:

   redirect: (state) {
          final loggedIn = _authState.isLoggedIn;
          final authPath = state.location != "/login" && state.location != "/";
          final loadingPath = state.location == "/loading";

          // the user is not logged in and not headed to /login, they need to login
          if (!loggedIn && authPath) return '/login';

          // the user is logged in and headed to /login, no need to login again
          if (loggedIn && !authPath) {
            return "/home";
          }

          // no need to redirect at all
          return null;
        },

This works great, the only problem is, if I refresh the page the authstate is momentarily interdeterminate. I can use this state and assume the user is logged out, which the above code down, but then for a hot second it shows the login screen. I would rather it show a loading screen in this intermediate auth state.

There's 2 approaches I can think of to address this use case. 1: in my actual login view, show a loading screen during an intermediate authState. This does not work, as then, for a very hot second, the screen will update to the login screen before the router redirects to the authenticated page 2: A bit hacky, but to do this on the router level, something like:

        redirect: (state) {
          final loggedIn = _authState.isLoggedIn;
          final loading = _authState.isLoading;
          final loadingPath = state.location == "/loading";
          final authPath = state.location != "/login" &&
              state.location != "/" &&
              !loadingPath;

          if (loading && loadingPath) return null;

          if (loading) return "/loading";

          if (!loggedIn && (authPath || loadingPath)) return "/login";

          if (loggedIn && (!authPath || loadingPath)) return "/home";

          return null;
        },

This works perfectly from a UX standpoint, but has the minor problem that it includes an intermediate "loading" in the auth path, and just feels hacky. Is this the preferred way to solve this problem?

csells commented 2 years ago

Why is the auth state indeterminate? Can you post a minimal sample that shows the login page for a "hot second"?

kornha commented 2 years ago

Sure. The authState will be intermediate because the server has yet to respond with authenticated or unauthenticated. For example, here is a changenotifier that this would exist in:

class AuthState extends ChangeNotifier {
  AuthStatus status = AuthStatus.unknown;
  final Auth _auth = Auth();
  User? authUser;

  bool get isLoggedIn => status == AuthStatus.authenticated;
  bool get isLoading =>
      status == AuthStatus.unknown || status == AuthStatus.authenticating;

  AuthState.instance() {
    status = AuthStatus.unknown;
    _auth.auth.authStateChanges().listen((user) async {
      authUser = user;
      if (user == null) {
        status = AuthStatus.unauthenticated;
      } else {
        status = AuthStatus.authenticated;
      }
      notifyListeners();
    });
  }
}

Note that, in the above, before a server response we will have state == unknown.

kornha commented 2 years ago

The problem with showing the login page with a loading indicator -> redirecting to an authenticated page, is that it creates a race condition. The loading indicator and the redirect will both listen on a change in authState. So if the loading indicator hears the change first, it will vanish, then show the login page (for an instant.. so I guess I could do a 'sleep' here but certainly not ideal), then redirect to the authenticated page. Does that make sense? I can image capture if not.

kornha commented 2 years ago

A subtle annoyance with my proposed solution as well is that it requires deeplinking to be implemented in the loading path.

lulupointu commented 2 years ago

May this help ? (I used null for AuthState.unknown for simplicity)

Click to show code ```dart import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; void main() => runApp(BooksApp()); class Credentials { final String username; final String password; Credentials(this.username, this.password); } abstract class Authentication { Future isSignedIn(); Future signOut(); Future signIn(String username, String password); } class MockAuthentication implements Authentication { bool _signedIn = false; @override Future isSignedIn() async { return _signedIn; } @override Future signOut() async { _signedIn = false; } @override Future signIn(String username, String password) async { return _signedIn = true; } } class AuthState extends ChangeNotifier { final Authentication _auth; bool? _isSignedIn = null; AuthState({required Authentication auth}) : _auth = auth; Future initialize() async { await Future.delayed(Duration(seconds: 2)); _isSignedIn = false; notifyListeners(); } Future signIn(Credentials credentials) async { var success = await _auth.signIn(credentials.username, credentials.password); _isSignedIn = success; notifyListeners(); return success; } Future signOut() async { await _auth.signOut(); _isSignedIn = false; notifyListeners(); } bool? get isSignedIn => _isSignedIn; } class BooksApp extends StatefulWidget { @override State createState() => _BooksAppState(); } class _BooksAppState extends State { final authState = AuthState(auth: MockAuthentication()); @override void initState() { authState.initialize(); super.initState(); } @override Widget build(BuildContext context) => MaterialApp.router( routeInformationParser: _router.routeInformationParser, routerDelegate: _router.routerDelegate, title: 'GoRouter Example: LogIn', ); late final _router = GoRouter( navigatorBuilder: (_, child) => AuthStateUpdateHandler( child: child!, authState: authState, ), redirect: (state) { final isSignedIn = authState.isSignedIn; if (isSignedIn == null && state.location != '/loading') { return '/loading'; } if (isSignedIn == false && state.location != '/sign-in') { return '/sign-in'; } if (isSignedIn == true && (state.location == '/loading' || state.location == '/sign-in')) { return '/'; } }, routes: [ GoRoute( path: '/', pageBuilder: (_, state) => MaterialPage( key: state.pageKey, child: HomeScreen(onSignOut: authState.signOut), ), routes: [ GoRoute( path: 'books', pageBuilder: (_, state) => MaterialPage( key: state.pageKey, child: BooksListScreen(), ), ), ], ), GoRoute( path: '/sign-in', pageBuilder: (_, state) => MaterialPage( key: state.pageKey, child: SignInScreen(onSignedIn: authState.signIn), ), ), GoRoute( path: '/loading', pageBuilder: (_, state) => MaterialPage( key: state.pageKey, child: Center(child: Text('Loading signin state')), ), ), ], errorPageBuilder: (context, state) => MaterialPage( key: state.pageKey, child: Text('${state.error}'), ), ); } class AuthStateUpdateHandler extends StatefulWidget { final Widget child; final AuthState authState; AuthStateUpdateHandler({required this.child, required this.authState}); @override State createState() => _AuthStateUpdateHandlerState(); } class _AuthStateUpdateHandlerState extends State { void _onAuthStateChange() { final isSignedIn = widget.authState.isSignedIn; if (isSignedIn == null) return context.go('/loading'); context.go(isSignedIn ? '/' : '/log-in'); } @override void initState() { super.initState(); widget.authState.addListener(_onAuthStateChange); } @override void dispose() { widget.authState.removeListener(_onAuthStateChange); super.dispose(); } @override Widget build(BuildContext context) => widget.child; } class HomeScreen extends StatelessWidget { final VoidCallback onSignOut; HomeScreen({required this.onSignOut}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Column( children: [ ElevatedButton( onPressed: () => context.go('/books'), child: Text('View my bookshelf'), ), ElevatedButton( onPressed: onSignOut, child: Text('Sign out'), ), ], ), ), ); } } class SignInScreen extends StatefulWidget { final ValueChanged onSignedIn; SignInScreen({required this.onSignedIn}); @override _SignInScreenState createState() => _SignInScreenState(); } class _SignInScreenState extends State { String _username = ''; String _password = ''; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Column( children: [ TextField( decoration: InputDecoration(hintText: 'username (any)'), onChanged: (s) => _username = s, ), TextField( decoration: InputDecoration(hintText: 'password (any)'), obscureText: true, onChanged: (s) => _password = s, ), ElevatedButton( onPressed: () => widget.onSignedIn(Credentials(_username, _password)), child: Text('Sign in'), ), ], ), ), ); } } class BooksListScreen extends StatelessWidget { BooksListScreen(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: ListView( children: [ ListTile( title: Text('Stranger in a Strange Land'), subtitle: Text('Robert A. Heinlein'), ), ListTile( title: Text('Foundation'), subtitle: Text('Isaac Asimov'), ), ListTile( title: Text('Fahrenheit 451'), subtitle: Text('Ray Bradbury'), ), ], ), ); } } ```

This could be improved of course but I think the core idea is there

csells commented 2 years ago

I'm sorry. I'm looking for a complete minimal repro, ie an app that I can load into my ide and run.

lulupointu commented 2 years ago

Oh actually I was giving a solution to @kornha ^^

csells commented 2 years ago

Ha. And I was asking @kornha for a repro so I could understand the problem. One step ahead as usual @lulupointu. : )