The previous developer has implemented an authentication mechanism that's very hit or miss when it comes to successfully authenticating users. I've been trying to debug the issues, but this last one persists.

The logic for the authentication is the following:

  1. Users opens the app and is navigated to the splash screen
  2. The splash screen's cubit makes an async request to get an AuthModel that contains the Firebase user's UUID and if successful, emits an "already logged in" state
  3. The splash screen then reacts to the state and navigates the user to the main screen
  4. The main screen seems to listen for event emissions by the AppBloc & MainBloc in order to determine if the user can be shown the actual content.

The issue is that although steps (1), (2) & (3) seem to initially work, after running the app in debug mode a lot, the AppBloc's on<AppEventAuthModelChanged$>(_onUserChanged); runs after receiving a new AuthModel emission and inside the _onUserChanged function, we land on the return emit(const AppState.unauthenticated()) statement. After observing the debugger, this is caused because the _authRepository.token != null expression is false at that point (but becomes true later).

This token is retrieved via the await _connectToServer(firebaseUser: firebaseUser); call inside the Auth Repository Impl's_ensureUser() function that runs based on the authStateChanges() stream via the getter.

Now, I have observed that the _ensureUser() is run multiple times and always seems to emit a valid Firebase user. However, the call to the _connectToServer() seems to take too long to finish (in order to initialize the token field), and thus the AppBloc's logic in the _onUserChanged call fails. What would be a valid approach so that the call has finished in order for us to be sure that the token was either retrieved or that something actually went wrong?

Is the code unsalvageable?

Splash_component.dart for the splash_screen.dart:

class SplashComponent extends StatefulWidget {
  const SplashComponent({Key? key}) : super(key: key);

  @override
  State<SplashComponent> createState() => _SplashComponentState();
}

class _SplashComponentState extends State<SplashComponent> {
  SplashCubit get _cubit => context.read<SplashCubit>();

  bool _isTextVisible = false;

  @override
  void initState() {
    super.initState();
    _cubit.init();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: AppColors.red,
      body: BlocConsumer<SplashCubit, SplashState>(
        listener: (BuildContext context, SplashState state) {
          state.maybeWhen(
            newUser: () async {
              setState(() => _isTextVisible = true);
              //await Future<void>.delayed(const Duration(seconds: 1));
              NavigatorUtils.replaceToAuthScreen(context);
            },
            alreadyLoggedIn: () async {
              setState(() => _isTextVisible = true);
              //await Future<void>.delayed(const Duration(seconds: 1));
              NavigatorUtils.replaceToMainScreen(context);
            },
            orElse: () {},
          );
        },
        builder: (BuildContext context, SplashState state) {
          return state.maybeWhen(
            orElse: () {
              return Padding(
                padding: const EdgeInsets.all(40),
                child: Column(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: <Widget>[
                    Image.asset('assets/images/blob_logo_white_splash.png'),
                    const SizedBox(height: 24),
                    AnimatedOpacity(
                      duration: const Duration(milliseconds: 500),
                      opacity: _isTextVisible ? 1.0 : 0.0,
                      child: Text(
                        'Carry your art with you',
                        style: montserratRegular22.copyWith(
                            color: AppColors.white),
                      ),
                    ),
                  ],
                ),
              );
            },
          );
        },
      ),
    );
  }
}

Splash_cubit.dart:

class SplashCubit extends Cubit<SplashState> {
  SplashCubit({required AuthRepository authRepository})
      : _authRepository = authRepository,
        super(const SplashState.loading());

  final AuthRepository _authRepository;

  Future<void> init() async {
    try {
      final AuthModel? authModel = await _authRepository.authModel.first;

      if (authModel != null) {
        return emit(const SplashState.alreadyLoggedIn());
      }

      return emit(const SplashState.newUser());
    } catch (_) {
      return emit(const SplashState.newUser());
    }
  }
}

Main_component.dart for the main_screen.dart:

const double _iconSize = 24;

class MainComponent extends StatelessWidget {
  const MainComponent({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return BlocListener<AppBloc, AppState>(
      listenWhen: (AppState previous, AppState current) {
        return previous is AppStateAuthenticated$ &&
            current is AppStateUnauthenticated$;
      },
      listener: (BuildContext context, AppState state) {
        state.maybeWhen(
          unauthenticated: () => NavigatorUtils.replaceToAuthScreen(context),
          orElse: () {},
        );
      },
      child: BlocBuilder<MainCubit, MainState>(
        builder: (BuildContext context, MainState state) {
          final MainCubit cubit = context.read<MainCubit>();
          final AppState appState = context.watch<AppBloc>().state;

          final TabType tab = state.tab;

          return Scaffold(
            body: appState.maybeWhen(
              authenticated: (_) {
                return SafeArea(
                  child: IndexedStack(
                    index: tab.index,
                    children: const <Widget>[
                      AlbumScreen(),
                      CameraScreen(),
                      ExploreScreen(),
                    ],
                  ),
                );
              },
              unauthenticated: () {
                NavigatorUtils.replaceToAuthScreen(context);
                return const SizedBox.shrink();
              },
              orElse: () => const SizedBox.shrink(),
            ),
            bottomNavigationBar: BottomNavigationBar(
              onTap: (int index) => cubit.changeTab(TabType.values[index]),
              currentIndex: cubit.state.tab.index,
              elevation: 0,
              selectedItemColor: AppColors.red,
              selectedFontSize: 12,
              items: <BottomNavigationBarItem>[
                BottomNavigationBarItem(
                  icon: Image.asset(TabType.album.asset,
                      color: AppColors.grey,
                      height: _iconSize,
                      width: _iconSize),
                  activeIcon: Image.asset(TabType.album.asset,
                      color: AppColors.red,
                      height: _iconSize,
                      width: _iconSize),
                  label: 'ALBUM',
                ),
                BottomNavigationBarItem(
                  icon: Image.asset(
                    TabType.camera.asset,
                    color: AppColors.grey,
                    height: _iconSize,
                    width: _iconSize,
                  ),
                  activeIcon: Image.asset(
                    TabType.camera.asset,
                    color: AppColors.red,
                    height: _iconSize,
                    width: _iconSize,
                  ),
                  label: 'CAMERA',
                ),
                BottomNavigationBarItem(
                  icon: Image.asset(
                    TabType.explore.asset,
                    color: AppColors.grey,
                    height: _iconSize,
                    width: _iconSize,
                  ),
                  activeIcon: Image.asset(
                    TabType.explore.asset,
                    color: AppColors.red,
                    height: _iconSize,
                    width: _iconSize,
                  ),
                  label: 'EXPLORE',
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

app_bloc.dart:

class AppBloc extends Bloc<AppEvent, AppState> {
  AppBloc({
    required AuthRepository authRepository,
    required UserRepository userRepository,
    required ArtworkRepository artworkRepository,
    required ArtistRepository artistRepository,
    required GalleryRepository galleryRepository,
    required VenueRepository venueRepository,
  })  : _authRepository = authRepository,
        _userRepository = userRepository,
        _artworkRepository = artworkRepository,
        _artistRepository = artistRepository,
        _galleryRepository = galleryRepository,
        _venueRepository = venueRepository,
        super(const AppState.initial()) {
    on<AppEventAuthModelChanged$>(_onUserChanged);
    on<AppEventLogout$>(_onLogout);
    _authModelSubscription = _authRepository.authModel.listen(
      (AuthModel? authModel) => add(AppEvent.authModelChanged(authModel)),
    );
  }

  final AuthRepository _authRepository;
  final UserRepository _userRepository;
  final ArtworkRepository _artworkRepository;
  final ArtistRepository _artistRepository;
  final GalleryRepository _galleryRepository;
  final VenueRepository _venueRepository;

  late StreamSubscription<AuthModel?> _authModelSubscription;

  Future<void> _onUserChanged(
      AppEventAuthModelChanged$ event, Emitter<AppState> emit) async {
    final AuthModel? authModel = event.authModel;
    if (authModel != null &&
        state is! AppStateUnauthenticated$ &&
        _authRepository.token != null) {
      final String token = _authRepository.token!;

      _userRepository.token = token;
      _artworkRepository.token = token;
      _artistRepository.token = token;
      _galleryRepository.token = token;
      _venueRepository.token = token;

      await _artworkRepository.getSavedArtworks();

      final User user = await _userRepository.getUserDetails();

      return emit(AppState.authenticated(user: user));
    } else {
      _authRepository.token = null;
      _artworkRepository.token = null;
      _artistRepository.token = null;

      return emit(const AppState.unauthenticated());
    }
  }

  Future<void> _onLogout(AppEventLogout$ event, Emitter<AppState> emit) async {
    unawaited(_authRepository.logOut());
    emit(const AppState.unauthenticated());
  }

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

auth_repository_impl.dart:

class AuthRepositoryImpl implements AuthRepository {
  AuthRepositoryImpl({required DioClient dioClient}) : _client = dioClient;

  final DioClient _client;

  final auth.FirebaseAuth _auth = auth.FirebaseAuth.instance;

  @override
  String? token;
  bool isFetchingToken = false;

  @override
  Stream<AuthModel?> get authModel {
    return MergeStream<auth.User?>(
      <Stream<auth.User?>>[
        _auth.authStateChanges(),
      ],
    )
        .startWith(_auth.currentUser) //
        .switchMap<AuthModel?>(_ensureUser)
        .share()
        .distinct();
  }

  Stream<AuthModel?> _ensureUser(auth.User? firebaseUser) async* {
    if (firebaseUser == null) {
      yield* Stream<AuthModel?>.value(null);
      return;
    }

    if (token == null && !isFetchingToken) {
      await _connectToServer(firebaseUser: firebaseUser);
    }

    yield* Stream<AuthModel?>.value(AuthModel(uid: firebaseUser.uid));
  }

  Future<void> _connectToServer(
      {auth.UserCredential? userCredential, auth.User? firebaseUser}) async {
    try {
      String? firebaseToken;
      isFetchingToken = true;

      if (userCredential != null) {
        firebaseToken = await userCredential.user?.getIdToken();
      } else {
        firebaseToken = await firebaseUser?.getIdToken();
      }

      final String email =
          userCredential?.user?.email ?? firebaseUser?.email ?? '';

      final String baseUrl = getIt<AppRepository>().env == Env.staging
          ? stagingUrl
          : productionUrl;

      final ApiResponse response = await _client.httpCall(
        baseUrl: baseUrl,
        path: '/mobile/login',
        httpMethod: HttpMethod.POST,
        queryParameters: <String, dynamic>{
          'email': email,
          'token': firebaseToken,
        },
      );

      final Map<String, dynamic> data = response.data;

      token = data['message'];
      isFetchingToken = false;
    } on DioError catch (ex) {
      if (ex.type == DioErrorType.connectTimeout) {
        throw Exception('Connection Timeout Exception');
      }
      throw Exception(ex.message);
    }
  }

  @override
  Future<void> logOut() async => await _auth.signOut();

  @override
  Future<void> registerWithEmailAndPassword(
      {required String email, required String password}) async {
    final auth.UserCredential userCredential =
        await _auth.createUserWithEmailAndPassword(
      email: email,
      password: password,
    );

    await _connectToServer(userCredential: userCredential);
  }

  @override
  Future<void> loginWithApple() async {
    final String rawNonce = NonceGenerator.generateNonce();
    final String nonce = NonceGenerator.sha256ofString(rawNonce);

    final AuthorizationCredentialAppleID appleCredential =
        await SignInWithApple.getAppleIDCredential(
      scopes: <AppleIDAuthorizationScopes>[
        AppleIDAuthorizationScopes.email,
        AppleIDAuthorizationScopes.fullName
      ],
      nonce: nonce,
    );

    final auth.OAuthCredential oauthCredential =
        auth.OAuthProvider('apple.com').credential(
      idToken: appleCredential.identityToken,
      rawNonce: rawNonce,
    );

    final auth.UserCredential userCredential =
        await auth.FirebaseAuth.instance.signInWithCredential(oauthCredential);

    await _connectToServer(userCredential: userCredential);
  }

  @override
  Future<void> loginWithEmailAndPassword(
      {required String email, required String password}) async {
    try {
      final auth.UserCredential userCredential =
          await _auth.signInWithEmailAndPassword(
        email: email,
        password: password,
      );

      await _connectToServer(userCredential: userCredential);

      log('z1z:: Server token $token');
    } catch (e) {
      rethrow;
    }
  }

  @override
  Future<void> resetPassword({required String email}) async =>
      await _auth.sendPasswordResetEmail(email: email);
}