androidflutterblocflutter-providerauto-route

Declarative Navigation using AutoRoute in Flutter


I'm trying to implement declarative navigation in my Flutter app using the AutoRoute package. I have a complex navigation structure defined in my AppRouter class, and I'm using the AuthCubit for managing user authentication status. I would like to achieve declarative navigation based on the user's authentication status using AutoRoute.

auth_cubit.dart:

part 'auth_state.dart';

part 'auth_cubit.freezed.dart';

@injectable
class AuthCubit extends Cubit<AuthState> {
  final AuthStateRepository _authStateRepository;
  StreamSubscription<bool>? _authSubscription;

  AuthCubit(this._authStateRepository) : super(const AuthState.isLoggedIn()) {
    _init();
  }

  void _init() {
    _authSubscription =
        _authStateRepository.isUserLoggedIn.listen((isLoggedIn) {
      if (isLoggedIn) {
        emit(const AuthState.isLoggedIn());
      } else {
        emit(const AuthState.isLoggedOut());
      }
    });
  }

  @override
  Future<void> close() {
    _authSubscription?.cancel();
    return super.close();
  }
}

auth_state.dart:

part of 'auth_cubit.dart';

@freezed
class AuthState with _$AuthState {
  const factory AuthState.isLoggedIn() = _IsLoggedIn;
  const factory AuthState.isLoggedOut() = _IsLoggedOut;
}

auth_state_repository.dart:

  @lazySingleton
  class AuthStateRepository {

    final BehaviorSubject<bool> _isUserLoggedIn = BehaviorSubject<bool>();

    void setUserLoggedIn(bool isLoggedIn) {
      _isUserLoggedIn.add(isLoggedIn);
    }
    Stream<bool> get isUserLoggedIn => _isUserLoggedIn.stream;
  }

token_repository.dart:

@singleton
class TokenRepository {
  TokenRepository(this._authStateRepository) {
    _init();
  }

  void _init() {
    _checkForAccessToken();
    _checkForRefreshToken();
  }

  final AuthStateRepository _authStateRepository;

  String? _accessToken;
  String? _refreshToken;

  //get refresh token
  FutureOr<String?> get refreshToken async {
    if (_refreshToken != null) {
      return _refreshToken;
    }
    return readRefreshToken().then((token) {
      _refreshToken = token;
      return token;
    });
  }

  //get access token
  FutureOr<String?> get accessToken async {
    if (_accessToken != null) {
      return _accessToken;
    }
    return readAccessToken().then((token) {
      _accessToken = token;
      return token;
    });
  }

  //check if token is expired
  Future<bool> tokenIsExpired() async {
    final token = await readAccessToken();
    return token == null || token.isEmpty;
  }

  //load token from secure storage
  Future<void> loadAccessToken() async {
    final token = await readAccessToken();
    if (token != null) {
      _accessToken = token;
    }
  }

  //load refresh token from secure storage
  Future<void> loadRefreshToken() async {
    final token = await readRefreshToken();
    if (token != null) {
      _refreshToken = token;
    }
  }

  //check if refresh token exists
  Future<bool> _hasRefreshToken() async {
    final token = await readRefreshToken();
    return token != null && token.isNotEmpty;
  }

  Future<void> _checkForRefreshToken() async {
    _hasRefreshToken().then((hasToken) {
      _authStateRepository.setUserLoggedIn(hasToken);
    });
  }

  //check if access token exists
  Future<bool> _hasAccessToken() async {
    final token = await readAccessToken();
    return token != null && token.isNotEmpty;
  }

  Future<void> _checkForAccessToken() async {
    _hasAccessToken().then((hasToken) {
      _authStateRepository.setUserLoggedIn(hasToken);
    });
  }

  //read refresh token from secure storage
  Future<String?> readRefreshToken() async {
    try {
      const secureStorage = FlutterSecureStorage();
      return secureStorage.read(key: _refreshTokenKey);
    } catch (e) {
      return null;
    }
  }

  //read access token from secure storage
  Future<String?> readAccessToken() async {
    try {
      const secureStorage = FlutterSecureStorage();
      return secureStorage.read(key: _accessTokenKey);
    } catch (e) {
      return null;
    }
  }

  //save refresh token to secure storage
  Future<bool> saveRefreshToken(String? token) async {
    try {
      const secureStorage = FlutterSecureStorage();
      await secureStorage.write(
        key: _refreshTokenKey,
        value: token,
      );
      _refreshToken = token;
      return true;
    } catch (e) {
      return false;
    }
  }

  //save access token to secure storage
  Future<bool> saveAccessToken(String? token) async {
    try {
      const secureStorage = FlutterSecureStorage();
      await secureStorage.write(
        key: _accessTokenKey,
        value: token,
      );
      _accessToken = token;
      return true;
    } catch (e) {
      return false;
    }
  }

  Future<void> refreshAccessToken() async {
    if (await tokenIsExpired()) {
      final refreshToken = await readRefreshToken();
      final accessToken = await readAccessToken();
      if (refreshToken != null && accessToken != null) {
          final response = await getIt<ApiDatasource>().refreshToken(
            UseTokenModel(accessToken: accessToken, refreshToken: refreshToken),
          );
          final newAccessToken = response.accessToken;

          await saveAccessToken(newAccessToken);

          _accessToken = newAccessToken;
      }
    }
  }
}

token_interceptor.dart:

@injectable
class TokenInterceptor extends Interceptor {
  final TokenRepository _tokenRepository;


  TokenInterceptor(this._tokenRepository);

  @override
  Future<void> onRequest(
      RequestOptions options,
      RequestInterceptorHandler handler,
      ) async {
    final String? token = await _tokenRepository.accessToken;
    if (token != null) {
      options.headers['Authorization'] = 'Bearer $token';
    }
    handler.next(options);
  }

  @override
  Future<void> onError(
      DioException err,
      ErrorInterceptorHandler handler,
      ) async {
    if (err.response?.statusCode == 401 || err.response?.statusCode == 403) {
      _tokenRepository.refreshAccessToken();
    }
    handler.next(err);
  }
}

I want to conditionally navigate users based on their authentication status. For example, if a user is logged in, they should be directed to the DashboardRoute page. If they are not logged in, they should be directed to the LoginRoute page.

How can I achieve declarative navigation in this setup? Should I modify my AppRouter or my AuthCubit? Can you provide an example of how to conditionally navigate users using the AutoRoute package?

Thank you in advance for your help!

Feel free to ask for more details in comments section


Solution

  • If anyone encounters same problem as mine, here is a solution.

    I've made AuthWrapperPage that wraps auth and dashboard pages in AppRouter:

    part 'app_router.gr.dart';
    
    @AutoRouterConfig()
    @lazySingleton
    class AppRouter extends _$AppRouter {
      @override
      List<AutoRoute> get routes => [
            AutoRoute(
              page: AuthWrapperRoute.page,
              initial: true,
              children: [
                AutoRoute(
                  page: DashboardRoute.page,
                  children: [
                    AutoRoute(page: AdoptionRoute.page),
                    AutoRoute(page: FavoritePetsRoute.page),
                    AutoRoute(page: MissingPetsRoute.page),
                    AutoRoute(page: MyPetsRoute.page),
                    AutoRoute(page: MessagesRoute.page),
                    AutoRoute(page: SettingsRoute.page),
                    AutoRoute(page: SubmissionsRoute.page),
                    AutoRoute(page: MissingRoute.page),
                    AutoRoute(page: VolunteeringRoute.page),
                  ],
                ),
                AutoRoute(
                  page: AuthRoute.page,
                  children: [
                    AutoRoute(page: LoginRoute.page,),
                    AutoRoute(page: RegisterRoute.page),
                    AutoRoute(page: PasswordResetEmailRoute.page),
                    AutoRoute(page: PasswordResetRoute.page),
                    AutoRoute(page: PasswordResetSuccessRoute.page),
                  ],
                ),
              ],
            ),
          ];
    }
    

    here is the wrapper class:

    @RoutePage()
    class AuthWrapperPage extends StatelessWidget implements AutoRouteWrapper {
      AuthWrapperPage({super.key});
    
      final AuthCubit authCubit = getIt<AuthCubit>();
      final AuthStateRepository authStateRepository = getIt<AuthStateRepository>();
    
      @override
      Widget build(BuildContext context) {
        return BlocBuilder<AuthCubit, AuthState>(
          builder: (context, state) {
            return WillPopScope(
              onWillPop: () async => false,
              child: AutoRouter.declarative(routes: (_) {
                return [
                  state.map(
                    isLoggedIn: (_) {
                      return const AdoptionRoute();
                    },
                    isLoggedOut: (_) {
                      return const LoginRoute();
                    },
                  )
                ];
              }),
            );
          },
        );
      }
    
      @override
      Widget wrappedRoute(BuildContext context) {
        return MultiBlocProvider(
          providers: [
            BlocProvider<AuthCubit>(
              create: (context) => authCubit,
            ),
          ],
          child: this,
        );
      }
    }
    

    I've used the blocProvider and blocbuilder, inside the AutoRouter.declarative I've mapped the states to the routes that I want navigate to. Here is updated TokenInterceptor

       @injectable
        class TokenInterceptor extends Interceptor {
          final TokenRepository _tokenRepository;
    
    
          TokenInterceptor(this._tokenRepository);
    
          @override
          Future<void> onRequest(
              RequestOptions options,
              RequestInterceptorHandler handler,
              ) async {
            final String? token = await _tokenRepository.accessToken;
            if (token != null) {
              options.headers['Authorization'] = 'Bearer $token';
            }
            handler.next(options);
          }
    
          @override
          Future<void> onError(
              DioException err,
              ErrorInterceptorHandler handler,
              ) async {
            if (err.response?.statusCode == 401 || err.response?.statusCode == 403) {
              _tokenRepository.refreshAccessToken();
            }
            handler.next(err);
          }
    
          @override
          Future<void> onResponse(
              Response response,
              ResponseInterceptorHandler handler,
              ) async {
            final responseBody = response.data as Map<String, dynamic>?;
            if (responseBody != null) {
              final String? accessToken = responseBody['accessToken'] as String?;
              final String? refreshToken = responseBody['refreshToken'] as String?;
              if (accessToken != null) {
                _tokenRepository.saveAccessToken(accessToken);
                _tokenRepository.recheckForAccessToken();
              }
              if (refreshToken != null) {
                _tokenRepository.saveRefreshToken(refreshToken);
              }
            }
            handler.next(response);
          }
        }
    

    now when I make call to the API the onResponse method gets access and refresh tokens and saves them to the SecureStorage, when you reopen the app tokenReposiitory init function checks if the token is saved to the SecureStorage, if it is the initial screen will be adoptionPage if any call will return response code 401 or 403 the refreshToken will be called.