The following should be achieved using Flutter, Riverpod and Firebase.
It’s about onboarding, firebase authentication getting additional user information from firebase.
(1) The app should display a start screen with signup options if no user is authenticated.
(2) The app should display an onboarding screen if user is sign-in but has not completed his profile (e.g. saved a name, picture, dob under FirebaseDatabase:/users/<UID>/)
(3) The app should show the main screen if the user profile exists.
(4) whenever there is any kind of network activity (or waiting for the SignInWithApple-popup) a loading indicator should be displayed.
I would like to have the code as clean as possible, avoiding unnecessary providers and providers that depend on other providers. Clean, smart, fast and easy to understand.
The problem with the following code is:
If the user is presented with the StartScreen and taps on SignInWithApple, the authProvider changes its state so “isLoading”. This seemingly correctly puts the StartScreen into showing a LoadingIndicator.
BUT: looking deeper into it, also the RootPage is listening to the AuthProvider, will rebuild and put itself in a loading state as well (totally disposing the StartScreen, right?). This should not happen.
In practice I so far see no problem with the solution but I have a very bad feeling that this is quite prone for errors….
I tried to have different states or even one provider per screen but then I ran into dependencies and make things really complicated.
class RootPage extends ConsumerWidget {
const RootPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
if (auth.status == AuthRepositoryStatus.isLoading) {
return const Scaffold(
backgroundColor: AppColors.swGreyLight,
body: Center(
child: Text(
"User authenticated verifiying profile....",
style: TextStyle(color: Colors.black),
),
),
);
}
if (auth.status == AuthRepositoryStatus.networkError) {
return Scaffold(
backgroundColor: AppColors.swGreyLight,
body: Center(
child: Column(
children: [
const Text(
"Network error (auth)....",
style: TextStyle(color: Colors.black),
),
ElevatedButton(onPressed: () => ref.read(authProvider).retry(), child: const Text("Retry"))
],
),
),
);
}
if (auth.status == AuthRepositoryStatus.onboardingRequired) {
return const OnboardingNameAgeScreen();
}
if (auth.status == AuthRepositoryStatus.notAuthenticated) {
return const StartScreen();
}
return const LocationPermissionsScreen();
}
}
class StartScreen extends ConsumerStatefulWidget {
const StartScreen({super.key});
@override
ConsumerState<ConsumerStatefulWidget> createState() => _StartScreenState();
}
class _StartScreenState extends ConsumerState<StartScreen> with TickerProviderStateMixin {
bool isLoading = false;
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(duration: const Duration(milliseconds: 500), vsync: this)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isLoading = ref.watch(authProvider).isAuthenticating;
return Scaffold(
extendBodyBehindAppBar: true,
body: LoadingOverlay(
isLoading: isLoading,
child: Container(
decoration: const BoxDecoration(
image: DecorationImage(image: AssetImage("assets/start.png"), fit: BoxFit.cover),
),
child: FadeTransition(
opacity: _controller,
child: Stack(
children: [
Container(color: Colors.black.withAlpha(60)),
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, top: 50),
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () {
HapticFeedback.heavyImpact();
Logger().info("Login Button");
navTo(context, const LoginPage());
},
child: const Text('Login', style: TextStyle(color: Colors.white, fontSize: 20)))
],
)),
Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
//
// Email
//
TextButton(
onPressed: () {
Logger().info("Register with Email");
HapticFeedback.heavyImpact();
navTo(context, const SignUpWithEmailScreen());
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
tr(context).startLoginWithEmail,
style: const TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
textAlign: TextAlign.center,
)
],
)),
//
// Apple
//
AppleAuthButton(onPressed: () => ref.read(authProvider).signInWithApple()),
const SizedBox(height: 20),
//
// Google
//
GoogleAuthButton(onTap: () => ref.read(authProvider).signInWithGoogle()),
const SizedBox(height: 30),
TextButton(
onPressed: () {
Logger().info("Terms");
HapticFeedback.heavyImpact();
showDialog(
context: context,
builder: (BuildContext context) => AlertDialog(
title: Text(tr(context).settingsTermsOfUse),
content: SingleChildScrollView(child: Text(tr(context).settingsTermsOfUseText)),
actions: <Widget>[
TextButton(
onPressed: () => Navigator.pop(context, 'OK'),
child: const Text('OK'),
),
],
),
);
},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//
// Terms
//
Text(
tr(context).settingsTermsOfUse,
style: const TextStyle(color: Colors.white, fontSize: 12, fontStyle: FontStyle.italic),
textAlign: TextAlign.center,
)
],
)),
const SizedBox(height: 30),
],
),
],
),
),
),
),
);
}
@override
void setState(VoidCallback fn) {
if (mounted) {
super.setState(fn);
}
}
}
final authProvider = ChangeNotifierProvider<AuthRepository>((ref) {
return AuthRepository();
});
enum AuthRepositoryStatus { isLoading, notAuthenticated, userHasProfile, networkError, onboardingRequired }
class AuthRepository extends ChangeNotifier {
AuthRepositoryStatus status = AuthRepositoryStatus.isLoading;
bool isAuthenticating = false;
bool isProbingLocalProfile = false;
String uid = "";
late TKUser localUser;
AuthRepository() {
FirebaseAuth.instance.authStateChanges().listen((user) async {
if (user == null) {
Logger().info("[AuthProvider] User not authenticated.");
Counter.increaseCounterOnce(CounterName.deviceids);
status = AuthRepositoryStatus.notAuthenticated;
notifyListeners();
} else {
uid = user.uid;
Logger().info("[AuthProvider] UID:$uid.");
try {
isProbingLocalProfile = true;
notifyListeners();
final hasUserProfile = await FBService.hasUserProfile(uid);
if (hasUserProfile) {
Logger().info("[AuthProvider] User has profile.");
status = AuthRepositoryStatus.userHasProfile;
} else {
Logger().info("[AuthProvider] Onboarding required.");
status = AuthRepositoryStatus.onboardingRequired;
}
isProbingLocalProfile = false;
notifyListeners();
} on FirebaseException catch (e) {
status = AuthRepositoryStatus.networkError;
Logger().info("[AuthProvider] ⛔️ Network Error ${e.code}");
Counter.increaseCounter(CounterName.network_error);
notifyListeners();
}
}
});
googleSignIn.onCurrentUserChanged.listen((GoogleSignInAccount? account) async {
if (account != null) {
final authentication = await account.authentication;
final accessToken = authentication.accessToken;
final idToken = authentication.idToken;
final credential = GoogleAuthProvider.credential(accessToken: accessToken, idToken: idToken);
try {
final _ = await FirebaseAuth.instance.signInWithCredential(credential);
Logger().info("[AuthProvider] ✅ SIG success");
Counter.increaseCounter(CounterName.uid_created);
} catch (e) {
Logger().info("[AuthProvider] ⛔️ SIG failed 2");
}
}
});
}
retry() async {
// wenn aufgrund eines network errors, das locale profile nicht geladen wurde, versuche es erneut.
// CODE REPETITION:
isProbingLocalProfile = true;
notifyListeners();
final hasUserProfile = await FBService.hasUserProfile(uid);
if (hasUserProfile) {
Logger().info("[AuthProvider] User has profile.");
status = AuthRepositoryStatus.userHasProfile;
} else {
Logger().info("[AuthProvider] Onboarding required.");
registerFirstStartUserDetails(uid);
status = AuthRepositoryStatus.onboardingRequired;
}
isProbingLocalProfile = false;
notifyListeners();
}
registerFirstStartUserDetails(String uid) {
// final uid = FirebaseAuth.instance.currentUser?.uid;
// if (uid == null) {
TKDatabase.minidash()
.ref("app_details")
.child(appShort)
.child(Date.toYYMMDD())
.child(appInstanceId)
.update({'appInstanceId': appInstanceId, 'first_start': Date.toYYMMDDhhMMss()});
}
signInWithApple() async {
Logger().info("[AuthProvider] SIA started");
isAuthenticating = true;
notifyListeners();
//
// Sign In With Apple Procedure
//
final scopes = [AppleIDAuthorizationScopes.email];
try {
final credential = await SignInWithApple.getAppleIDCredential(scopes: scopes);
final accessToken = credential.authorizationCode;
final idToken = credential.identityToken;
final creds = OAuthProvider('apple.com').credential(accessToken: accessToken, idToken: idToken);
try {
await FirebaseAuth.instance.signInWithCredential(creds);
Counter.increaseCounter(CounterName.uid_created);
Logger().info("[AuthProvider] ✅ SIA success");
} catch (e) {
Logger().info("[AuthProvider] ⛔️ SIA failed 2");
}
// You need to add "Sign in with Apple" inside Signing & Capabilities, this should work for Apple Authorization Error 1000.
} on SignInWithAppleException catch (e) {
if (e is SignInWithAppleAuthorizationException) {
Logger().info("[AuthProvider] ⛔️ ${e.code}");
} else {
Logger().info('[AuthProvider] ⛔️ $e');
}
}
isAuthenticating = false;
notifyListeners();
}
//
// Sign in with Google
//
signInWithGoogle() async {
Logger().info("[AuthProvider] SIG started");
isAuthenticating = true;
notifyListeners();
try {
await googleSignIn.signIn();
} catch (error) {
Logger().info('[AuthProvider] ⛔️ $error');
}
isAuthenticating = false;
notifyListeners();
}
signUpWithEmailAndPassword({required String email, required String password}) async {
isAuthenticating = true;
notifyListeners();
// loggen
await Future.delayed(Duration(seconds: 4));
print("Sign Up");
isAuthenticating = false;
notifyListeners();
}
signInWithEmailAndPassword({required String email, required String password}) async {
isAuthenticating = true;
notifyListeners();
// loggen
await Future.delayed(Duration(seconds: 4));
isAuthenticating = false;
notifyListeners();
}
signOut() async {
googleSignIn.signOut();
FirebaseAuth.instance.signOut();
}
deleteAccount({required String reason}) async {
FBService.deleteAccount();
signOut();
}
}
Marc Felden is a new contributor to this site. Take care in asking for clarification, commenting, and answering.
Check out our Code of Conduct.