· 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:
- If the
firebaseUser
is null when trying to get mycurrentAppUser
, wesignOut
and return an empty user (this will often result in going back to the onboarding page, which we’ll see later) - 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);
}
- I’m using the Riverpod generator - there’s no way I’m using the old way.
- I’ll use the userRepositoryProvider to call methods with
ref.read
in my UserRepository class (likeupdateUser
,login
) - 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:
- I initialize what needs to be initialized first (Firebase, Sqflite, Provider,…) in main()
- 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