· flutter · 5 min read

How to Set Up Auth with Firebase and Riverpod - Part 1

A simple implementation that I use for small apps.

I’ve published multiple Apps using Flutter. For most of them, I’m reusing the same packages and implementing things in a similar way.

Here is how I implemented Authentication with Riverpod in most of them.

I don’t claim it’s the best way, the most efficient, or anything like that - it’s just the way I did it and it worked :)

I’d be happy, in fact, to receive comments telling me if there is something really wrong with this implementation.

UserRepository

Here’s a very basic first implementation of the UserRepository class with FirebaseAuth and FirebaseFirestore.

currentUser will be the FirebaseUser and currentAppUser will be the user stored in Firestore.

class UserRepository {
  UserRepository(this._auth, this._firestore, this._ref);
  final FirebaseAuth _auth;
  final FirebaseFirestore _firestore;
  final Ref _ref;

  User? get currentUser => _auth.currentUser;

  DocumentReference<AppUser> _getAppUserRef(String uid) {
    return _firestore.doc('$usersCollectionKey/$uid').withConverter(
          fromFirestore: (doc, _) => AppUser.fromMap(doc.data()!, uid),
          toFirestore: (user, _) => user.toMap(),
        );
  }

  Future<AppUser> get currentAppUser async {
    final firebaseUser = currentUser;
    if (firebaseUser == null) {
      await signOut();
      return AppUser.empty();
    }
    final appUserRef = _getAppUserRef(firebaseUser.uid);

    final snapshot = await appUserRef.get();
    return snapshot.data()!;
  }

  Future<void> signOut() async {
    await _auth.signOut();
    await _ref.read(onboardingRepositoryProvider).cleanSharedPreferences();
    await _ref.read(appDatabaseProvider).cleanDatabase();
  }

  // I'll add more method to this class as we progress in the blog post
}

So there are multiple assumptions here:

  1. If the firebaseUser is null when trying to get my currentAppUser, we signOut and return an empty user (this will often result in going back to the onboarding page, which we’ll see later)
  2. The returned user will always exist (I force the return value to exist with !) - I don’t want to check everywhere if it’s null or not. (This might look dangerous, but I haven’t had any issues with this in 3 years).

Until now nothing complicated. As you can see, I didn’t use authStateChanges() - I’ve used it in another project but truly couldn’t see the advantages of it when using Riverpod the way I use it.


In my UserRepository file (but not in the class) I’m adding these providers at the end:

@riverpod
Future<AppUser> getCurrentAppUser(Ref ref) {
  return ref.watch(userRepositoryProvider).currentAppUser;
}

@riverpod
User? getCurrentFirebaseUser(Ref ref) {
  return ref.watch(userRepositoryProvider).currentUser;
}

@Riverpod(keepAlive: true)
UserRepository userRepository(Ref ref) {
  return UserRepository(FirebaseAuth.instance, FirebaseFirestore.instance, ref);
}
  1. I’m using the Riverpod generator - there’s no way I’m using the old way.
  2. I’ll use the userRepositoryProvider to call methods with ref.read in my UserRepository class (like updateUser, login)
  3. I’ll ref.watch the getCurrentAppUserProvider because I want it to react when that user’s state changes.

main.dart

void main() async {
  final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();
  FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);

  await Future.wait([
    CustomFirebase.ensureInitialized(),
    CustomPurchase.ensureInitialized(),
    AppDatabase.ensureInitialized(),
    SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
    SharedPreferences.getInstance(),
  ]).then((results) {
    final sharedPreferences = results[4] as SharedPreferences;
    SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle.dark);

    final container = ProviderContainer(
      overrides: [
        onboardingRepositoryProvider.overrideWithValue(
          OnboardingRepository(sharedPreferences),
        ),
      ],
    );

    runApp(
      UncontrolledProviderScope(
        container: container,
        child: const MyApp(),
      ),
    );
  });
}


class CustomMaterialApp extends HookConsumerWidget {
  const CustomMaterialApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      title: 'Pickleball Eye Trainer',
      debugShowCheckedModeBanner: false,
      navigatorObservers: [ref.read(firebaseAnalyticsObserverProvider)],
      theme: ThemeData(useMaterial3: true),
      home: const MyApp(),
    );
  }
}

class MyApp extends HookConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    FlutterNativeSplash.remove();

    return AsyncValueWidgetNoVisibleLoading<bool>(
      value:
          ref.watch(checkWhereToGoDependingUserAndOnboardingSituationProvider),
      data: (shouldGoToRealApp) {
        final home =
            shouldGoToRealApp ? const RealApp() : const OnboardingPage();

        return home;
      },
    );
  }
}


/// Not in the same file but I feel I should show that one :P


@riverpod
Future<bool> checkWhereToGoDependingUserAndOnboardingSituation(Ref ref) async {
  final onboardingRepo = ref.watch(onboardingRepositoryProvider);
  final userAlreadyExist = ref.watch(getCurrentFirebaseUserProvider) != null;
  final isOnboardingComplete = onboardingRepo.isOnboardingComplete();

  if (userAlreadyExist && !isOnboardingComplete) {
    await onboardingRepo.setDialogOnboardingComplete();
    await onboardingRepo.setOnboardingComplete();
    return true;
  }

  return isOnboardingComplete && userAlreadyExist;
}

What’s important here:

  1. I initialize what needs to be initialized first (Firebase, Sqflite, Provider,…) in main()
  2. Sometimes a user has deleted the app and reinstalled it. In such cases, they might still have a valid Firebase session (I’m not sure how Firebase manages this, but it does). So I check if this is the case and set the onboarding flag to true so it goes directly to the real app.

That’s all…

Somewhere in the app, I have buttons to sign out where I call the signOut method and invalidate the providers related to the user.

  await ref.read(userRepositoryProvider).signOut();
  ref
    ..invalidate(getCurrentAppUserProvider)
    ..invalidate(getCurrentFirebaseUserProvider)
    ..invalidate(onboardingRepositoryProvider);

After clicking that, it shows the onboarding again.


Update the user and login with Google / Apple

Here is the rest of the UserRepository

class UserRepository {
 Future<void> updateUser(Map<Object, Object?> data) async {
    try {
      await _getAppUserRef(currentUser!.uid).update(data);
    } catch (e) {
      await signOut();
    }
  }

  Future<void> updateUserWithUser(AppUser user) async {
    try {
      await _getAppUserRef(currentUser!.uid).update(user.toMap());
    } catch (e) {
      await signOut();
    }
  }

  Future<bool> linkUserWithGoogle() async {
    final googleUser = await GoogleSignIn().signIn();

    final googleAuth = await googleUser?.authentication;
    try {
      final credential = GoogleAuthProvider.credential(
        accessToken: googleAuth?.accessToken,
        idToken: googleAuth?.idToken,
      );

      try {
        final userCred = await currentUser?.linkWithCredential(credential);
        final appUser = await currentAppUser;
        await updateUser({
          'isAnonymous': false,
          'email': '${userCred?.user?.email}',
          'coins': appUser.currentCoin + 500,
        });

        return true;
      } on FirebaseAuthException catch (e) {
        unawaited(FirebaseCrashlytics.instance.recordError(e, null));

        switch (e.code) {
          case 'provider-already-linked':
          case 'invalid-credential':
          case 'credential-already-in-use':
            await _auth.signInWithCredential(credential);

            return true;
          default:
        }
      }
    } catch (e) {
      unawaited(FirebaseCrashlytics.instance.recordError(e, null));
      return false;
    }
    return false;
  }

  Future<bool> linkUserWithApple() async {
    final appleProvider = AppleAuthProvider()..addScope('email');

    try {
      final userCred = await currentUser?.linkWithProvider(appleProvider);
      final appUser = await currentAppUser;
      await updateUser({
        'isAnonymous': false,
        'email': '${userCred?.user?.email}',
        'coins': appUser.currentCoin + 500,
      });

      return true;
    } on FirebaseAuthException catch (e) {
      switch (e.code) {
        case 'provider-already-linked':
        case 'invalid-credential':
        case 'credential-already-in-use':
          await _auth.signInWithProvider(appleProvider);

          return true;
        default:
      }
    }
    return false;
  }
}

I do sometimes modify my user and then save it this way

randomMethod(){
  user
    ..fastestBall = stats.fastestBall
    ..currentDUPR = user.currentDUPR + ((clickTimes.length * 0.2) / 23)
    ..ballHit += clickTimes.length;
  ref
    ..read(userRepositoryProvider).updateUserWithUser(user)
    ..invalidate(getCurrentAppUserProvider);
}

or simply by providing an object like this

 await updateUser({
        'isAnonymous': false,
        'email': '${userCred?.user?.email}',
        'coins': appUser.currentCoin + 500,
      });

This is how I manage auth, a user in firestore with Riverpod in a Flutter App! In part 2, I’ll speak a bit more about how I initialize things, Crashalytics, Analytics, etc

Back to Blog