Dogly - a mobile app with Flutter: authentication

16 minute read

Introduction

Hello. In the first post from the series about creating a dog care mobile app - Dogly, the backend service - Appwrite was configured. It will help with managing some common features of the application. In this post, I will take care of authentication. The user of the application should be able to create an account, log in (instantly, after signing up), and also logout. The application should create a user model on sign-up and store it in the database. There should also be a method that will inform about the current authentication state (is the user is logged in or not). I will also touch the user collection which will be connected mostly with the user profile view, but authentication needs a method to save the user models into database collection.

Let’s get to work!

Providers for common Appwrite services

It is good to start by creating some global providers for common Appwrite services. I do this in the core/providers.dart because the providers might be useful across the whole code base. The providers for things like local databases, shared preference, HTTP clients, etc will be also here.

final appwriteClientProvider = Provider((ref) {
  Client client = Client();
  return client
      .setEndpoint(AppwriteConstants.endPoint)
      .setProject(AppwriteConstants.projectId);
});

final appwriteAccountProvider = Provider((ref) {
  final client = ref.watch(appwriteClientProvider);
  return Account(client);
});

final appwriteDbProvider = Provider((ref) {
  final client = ref.watch(appwriteClientProvider);
  return Databases(client);
});

final appwriteStorageProvider = Provider((ref) {
  final client = ref.watch(appwriteClientProvider);
  return Storage(client);
});

final appwriteRealtimeProvider = Provider((ref) {
  final client = ref.watch(appwriteClientProvider);
  return Realtime(client);
});

As you can see there is one provider for the Appwrite client, with some credentials which I store in the Appwrite Constants.

class AppwriteConstants {
  static const String projectId = 'someProjectIDd';
  static const String endPoint = 'https://some.endpoint/v1';
}

Other providers are responsible for common Appwrite services like:

  • accounts - service that allows you to authenticate and manage a user account,
  • database - service that allows you to create structured collections of documents, query and filter lists of documents, and manage an advanced set of read and write access permissions,
  • storage - service for uploading, viewing, downloading, and querying files in the buckets,
  • real-time - service that allows you to listen to server-side events in real-time using the subscribe method. The subscriber can receive new data every time it changes using WebSockets instead of HTTP requests.

Now the services are accessible in the Riverpod compliant code using ref.watch(appwriteSomeProvider). I am not sure yet if I will use the real-time service in the app, but let’s keep it for now.

AuthAPI

In simple words, AuthAPI will be an interface for executing methods related to authentication features. Following the good practices from the repository pattern I create an interface first. Dogly won’t have more than one concrete implementation of the AuthAPI, but having an abstract interface might be useful if, for example, I would like to change the Appwrite to Firebase. The new implementation will have to follow the rules defined in the IAuthAPI to keep the rest of the application working fine. As you can notice I use the non built-in type FutureEitherVoid. It is a custom type created with Either from a fpdart package. It represents a value of one of two possible types (left or right).

typedef FutureEither<T> = Future<Either<Failure, T>>;
typedef FutureEitherVoid = FutureEither<void>;

FutureEitherVoid represents a promise with a type of failure or void. Failure is just a combination of an error message and stack trace.

class Failure {
  final String message;
  final StackTrace stackTrace;

  const Failure(
    this.message,
    this.stackTrace,
  );
}

After writing the thousands of lines in Python I find this functional approach for handling errors so easy to understand.

Now it is time to create a concrete implementation of the interface. Visual Studio Code with Flutter SDK is helpful with automatically creating boilerplate code if the class implements some interface. If it is used, every method throws an unimplemented error, so let’s implement them. Firstly the class should require the Appwrite Account service. It will be provided via Riverpod, so I just create a private variable _account.

class AuthAPI implements IAuthAPI {
  final Account _account;

  AuthAPI({required Account account}) : _account = account;

Provider for the AuthAPI will look like this:

final authAPIProvider = Provider((ref) {
  final account = ref.watch(appwriteAccountProvider);
  return AuthAPI(account: account);
});

As you can see it watches for the account service provider and use it as an argument for AuthAPI.

The first method to implement is login. It requires an email and password. Here you can see how I handle the errors of API calls in the try-catch clause. If the call is ok the session object is returned as right with the type of session. When the code catches an Appwrite exception or some other problem it returns the left with failure. In the other layers of the application, I can just fold the result of this method and simply decide what to do if there is left or right, without a try-catch.

  @override
  FutureEither<Session> login({
    required String email,
    required String password,
  }) async {
    try {
      final session =
          await _account.createEmailSession(email: email, password: password);
      return right(session);
    } on AppwriteException catch (e, st) {
      return left(Failure(e.message ?? 'Some unexpected error :(', st));
    } catch (e, st) {
      return left(Failure(e.toString(), st));
    }
  }

Here you can see how I handle the errors of API calls in the try-catch clause. If the call is ok the session object is returned as right with the type of session. When the code catches an Appwrite exception or some other problem it returns the left with failure. In the other layers of the application, I can just fold the result of this method and simply decide what to do if there is left or right, without a try-catch.

The second method is to sign up. It returns the Appwrite user object. The user id is generated with the Appwrite method that makes sure it is unique.

  @override
  FutureEither<User> signUp({
    required String email,
    required String password,
  }) async {
    try {
      final account = await _account.create(
        userId: ID.unique(),
        email: email,
        password: password,
      );
      return right(account);
    } on AppwriteException catch (e, st) {
      return left(Failure(e.message ?? 'Some unexpected error :(', st));
    } catch (e, st) {
      return left(Failure(e.toString(), st));
    }
  }

The logout method just deletes the current user session. It is quite handy that Appwrite does not require you to know the id of the session, you can just pass ‘current’ as an id.

  @override
  FutureEitherVoid logout() async {
    try {
      await _account.deleteSession(sessionId: 'current');
      return right(null);
    } on AppwriteException catch (e, st) {
      return left(Failure(e.message ?? 'Some unexpected error :(', st));
    } catch (e, st) {
      return left(Failure(e.toString(), st));
    }
  }

The last method (at least for now) of the AuthAPI class is currentUserAccount.

  @override
  Future<User?> currentUserAccount() async {
    try {
      return await _account.get();
    } on AppwriteException {
      return null;
    } catch (e) {
      return null;
    }
  }

It obtains the currently logged user. It doesn’t handle the errors with failure type, because if there is a problem I would like to treat the app state like there is no user authenticated, so I just return null. I would also like to create the FutureProvider for the current user. It will be used a lot because the app will behave differently depending on the authentication status.

final currentUserProvider = FutureProvider<User?>((ref) async {
  ref.watch(authControllerProvider);
  final authRepository = ref.watch(authAPIProvider);
  return authRepository.currentUserAccount();
});

This provider watches for changes in the provider of the authentication controller that I will show in this post also. The provider refreshes whenever the state of the authentication controller changes. It is also possible to refresh the provider directly from the function pinned to the authentication buttons with ref.invalidate(provider). I am not sure which approach is better, so I just use the less code one.

UserAPI

User API is a set of methods related to user collection in the database. Let’s start with creating a user model. It will contain all fields describing the user and helpful methods to manage the object.

@immutable
class UserModel {
  final String email;
  final String name;
  final String profilePic;
  final String uid;
  static const empty = UserModel(uid: '', name: '', profilePic: '', email: '');

  const UserModel(
      {required this.email,
      required this.name,
      required this.profilePic,
      required this.uid});

  bool get isEmpty => this == UserModel.empty;

  UserModel copyWith({
    String? email,
    String? name,
    String? profilePic,
    String? uid,
  }) {
    return UserModel(
      email: email ?? this.email,
      name: name ?? this.name,
      profilePic: profilePic ?? this.profilePic,
      uid: uid ?? this.uid,
    );
  }

  Map<String, dynamic> toMap() {
    final result = <String, dynamic>{};

    result.addAll({'email': email});
    result.addAll({'name': name});
    result.addAll({'profilePic': profilePic});
    // result.addAll({'uid': uid}); // no need, appwrite creates it automatically

    return result;
  }

  factory UserModel.fromMap(Map<String, dynamic> map) {
    return UserModel(
      email: map['email'] ?? '',
      name: map['name'] ?? '',
      profilePic: map['profilePic'] ?? '',
      uid: map['\$id'],
    );
  }
 }

The empty static field allows me to easily create an empty user model. For example, it can be used for non-authenticated users. The copyWith method is created for updating the model because it is immutable so it cannot change its state. Some advantages of using immutable data are thread safety (it is guaranteed to be the same no matter what code is accessing it), code is more simple and easy to reason about, and no need to create a strategy to keep data away from changing in unexpected ways. The toMap and fromMap are here for serialization.

Now let’s create an abstract interface for User API.

abstract class IUserAPI {
  FutureEitherVoid saveUserData(UserModel userModel);
  Future<UserModel> getUserData(String uid);
  FutureEitherVoid updateUserData(UserModel userModel);
}

It has just three methods: save, update, and get user data. I think the app should also give the possibility to delete an account, but Appwrite does not have any method for this, which is weird. Maybe it is possible to build such functionality using Appwrite functions, but it is out of this post’s scope. I think it is enough to take a look just at the concrete implementation of the saveUserData method because the authorization uses it.

@override
  FutureEitherVoid saveUserData(UserModel userModel) async {
    try {
      await _db.createDocument(
          databaseId: AppwriteConstants.databaseId,
          collectionId: AppwriteConstants.usersCollection,
          documentId: userModel.uid,
          data: userModel.toMap());
      return right(null);
    } on AppwriteException catch (e, st) {
      return left(Failure(
        e.message ?? 'Some unexpected error',
        st,
      ));
    } catch (e, st) {
      return left(Failure(
        e.toString(),
        st,
      ));
    }
  }

The class uses the private variable _db, which is injected from appwriteDbProvider. The method uses the createDocument method, which requires the ids of the database and collection, and the id of the document, which is the value created with ID.unique() in the AuthAPI. To pass the data I use the serialization method toMap created in the UserModel. Failures are returned as left on exceptions. The userAPIProvider looks like this:

final userAPIProvider = Provider((ref) {
  final db = ref.watch(appwriteDbProvider);
  return UserAPI(db: db);
});

Nothing surprising here.

Authentication service

Services are parts of code that act as middle-man between the controllers and the repositories. They belong to the application layer of the Riverpod architecture. Creating services makes sense only when the functionality needs access to multiple repositories or multiple controllers share the same functionalities. The authentication feature already uses AuthAPI and UserAPI, but it could probably need access to almost every repository, for example if I would like to synchronize the local database with the remote one on the authorization. So there is a sense to create a service.

final authServiceProvider = Provider<AuthService>((ref) {
  return AuthService(
    authAPI: ref.watch(authAPIProvider),
    userAPI: ref.watch(userAPIProvider),
  );
});

class AuthService {
  final AuthAPI _authAPI;
  final UserAPI _userAPI;
    AuthService({
    required AuthAPI authAPI,
    required UserAPI userAPI,
  })  : _authAPI = authAPI,
        _userAPI = userAPI;

  FutureEither<Session> login({
    required String email,
    required String password,
  }) async {
    return await _authAPI.login(email: email, password: password);
  }

  FutureEitherVoid logout() async {
    return await _authAPI.logout();
  }

  FutureEither<User> signUp({
    required String email,
    required String password,
  }) async {
    return _authAPI.signUp(email: email, password: password);
  }

  FutureEitherVoid saveUserData(UserModel userModel) async {
    return await _userAPI.saveUserData(userModel);
  }
}

It might look like there is no sense to write code that does literally the same as in the implementation of APIs. To be honest, I would skip the service with just these three methods also, but I know that it will grow and the service will help me keep code easier to maintain.

Auth controller

A controller is a class that only manages the widget state. Controllers for apps using Provider or Riverpod usually extends StateNotifier. As the name suggests, it controls the state. The main difference from the built-in solution - ChangeNotifier is immutability. Authentication operations are asynchronous, so I need just two states - loading and not loading. The boolean type fits just perfectly. It is also a good idea to use the AsyncValue type for StateNotifier here, but it is not very suitable with the approach of handling errors with Either used at the API level. The controller will also handle the failures by showing the snack bars with error messages to the user. It could be also done on the UI level, I am not sure what is a better approach. Let’s look at the login method.

  void login({
    required String email,
    required String password,
    required BuildContext context,
  }) async {
    state = true;
    final res = await _authService.login(
      email: email,
      password: password,
    );
    state = false;
    res.fold((l) {
      showSnackBar(context, l.message);
    }, (r) {
        showSnackBar(context, 'Login successful!');
        Navigator.push(context, HomeView.route());
    });
  }

It changes the states to true before an await operation, which indicates it is loading. No matter if the call succeeds it sets the state to false after the end of execution. Then it folds the result. If the result is left, which means failure it shows the snack bar with an error message in the current context. If the result is right, which means a successful API call it shows the snack bar with a success message and moves the user to the home page. The showSnackBar function looks like this:

void showSnackBar(BuildContext context, String content) {
  ScaffoldMessenger.of(context).showSnackBar(SnackBar(
    content: Text(content),
  ));
}

I keep it in the utils.dart file in the core directory. I will use it a lot for displaying errors in a user-friendly way.

The signUp method shows the snack bar also.

 void signUp({
    required String email,
    required String password,
    required BuildContext context,
  }) async {
    state = true;
    final res = await _authService.signUp(
      email: email,
      password: password,
    );
    res.fold((l) {
      showSnackBar(context, l.message);
      state = false;
    }, (r) async {  
      UserModel userModel = UserModel(  
        email: email,  
        name: getNameFromEmail(email),  
        profilePic: AssetsConstants.defaultAvatar,  
        uid: r.$id,  
      );
      final res_2 = await _authService.saveUserData(userModel);
      state = false;

      res_2.fold((l) {
        showSnackBar(context, l.message);
      }, (r) {
        showSnackBar(context, 'Account created!');
        login(email: email, password: password, context: context);
      });
    });
  }

Firstly it creates an Appwrite account, then saves it to the user’s collection. The id field is created automatically and can be accessed using $id. The name of the user is obtained from an email using a simple utility function:

String getNameFromEmail(String email) {
  return email.split('@')[0];
}

If everything is fine the controller shows a success message on the snack bar and moves to the login method, so the user is logged in after the sign-up.

The method for logging out is super simple. It just destroys the session and removes all routes until the home view (so it is impossible for the user to reach an unwanted state of some view by accident).

  void logout(BuildContext context) async {
    state = true;
    final res = await _authService.logout();
    state = false;
    res.fold((l) {
      showSnackBar(context, l.message);
    }, (r) {
      Navigator.pushAndRemoveUntil(context, HomeView.route(), (route) => false);
    });
  }

The last thing to do here is to create a provider for the controller.

final authControllerProvider =
    StateNotifierProvider<AuthController, bool>((ref) {
  return AuthController(authService: ref.watch(authServiceProvider));
});

It takes as an input the value of authServiceProvider. This must be a provider of another type than the previous ones - StateNotifierProvider, so it needs to have defined types of controller and boolean.

Views

The application needs to separate views - log in and sign in. They will look almost the same. The first thing I create is a custom form field. I will use it across the whole app, so I place it in the commons directory.

class MyFormField extends StatelessWidget {
  final TextEditingController controller;
  final String hintText;

  const MyFormField({
    super.key,
    required this.controller,
    required this.hintText,
  });

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      controller: controller,
      decoration: InputDecoration(
          focusedBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(5),
              borderSide: const BorderSide(
                color: Colors.black87,
                width: 3,
              )),
          enabledBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(5),
              borderSide: const BorderSide(
                color: Colors.black54,
                width: 3,
              )),
          contentPadding: const EdgeInsets.all(22),
          hintText: hintText),
    );
  }
}

It takes the controller and hints text as arguments. It has different colors on the border whether it is focused or not. It will help me keep the style of form fields consistent.

The authentication needs also a different field for the password. It should have the stars as text and the possibility to show a password. It is connected with the authorization feature only, so I keep it in the /auth/presentation/widgets directory.

class PasswordField extends StatelessWidget {
  final TextEditingController controller;
  final String hintText;
  final bool obscureText;
  final VoidCallback onPressed;

  const PasswordField(
      {super.key,
      required this.controller,
      required this.hintText,
      required this.obscureText,
      required this.onPressed});

  @override
  Widget build(BuildContext context) {
    return TextFormField(
      obscureText: obscureText,
      autocorrect: false,
      controller: controller,
      decoration: InputDecoration(
          focusedBorder: OutlineInputBorder(
              borderRadius: BorderRadius.circular(5),
              borderSide: const BorderSide(
                color: Colors.black87,
                width: 3,
              )),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(5),
            borderSide: const BorderSide(
              color: Colors.black54,
              width: 3,
            ),
          ),
          suffixIcon: IconButton(
            icon: FaIcon(
                obscureText ? FontAwesomeIcons.eye : FontAwesomeIcons.eyeSlash),
            // color: Colors.lightBlue,
            // focusColor: Colors.blue,
            onPressed: onPressed,
          ),
          contentPadding: const EdgeInsets.all(22),
          hintText: hintText),
    );
  }
}

It is almost the same as the normal form field, but it has autocorrect disabled, text can be obscured and pretty eyes icons as a button to show/hide text. It is a stateless widget because I will handle the state from the view widget, which is stateful.

So let’s check the sign-up view now.

class SignUpView extends ConsumerStatefulWidget {
  static route() => MaterialPageRoute(
        builder: (context) => const SignUpView(),
      );

  const SignUpView({super.key});

  @override
  ConsumerState<SignUpView> createState() => _SignUpViewState();
}

class _SignUpViewState extends ConsumerState<SignUpView> {
  final appbar = UIConstants.titleAppBar();
  final emailController = TextEditingController();
  final passwordController = TextEditingController();
  bool obscureText = true;

  @override
  void dispose() {
    super.dispose();
    emailController.dispose();
    passwordController.dispose();
  }

  void onSignUp() {
    ref.read(authControllerProvider.notifier).signUp(
        email: emailController.text,
        password: passwordController.text,
        context: context);
  }

  @override
  Widget build(BuildContext context) {
    final authState = ref.watch(authControllerProvider);
    return Scaffold(
        appBar: appbar,
        body: authState
            ? const Loader()
            : Center(
                child: SingleChildScrollView(
                    child: Padding(
                        padding: const EdgeInsets.symmetric(horizontal: 20),
                        child: Column(children: [
                          MyFormField(
                            controller: emailController,
                            hintText: 'Email',
                          ),
                          const SizedBox(height: 5),
                          PasswordField(
                              obscureText: obscureText,
                              controller: passwordController,
                              hintText: 'Password',
                              onPressed: () {
                                setState(() {
                                  obscureText = !obscureText;
                                });
                              }),
                          const SizedBox(height: 10),
                          Align(
                            alignment: Alignment.topRight,
                            child: RoundedTinyButton(
                              onTap: onSignUp,
                              label: 'Done',
                              // backgroundColor: Colors.blue,
                              // textColor: Colors.white,
                            ),
                          ),
                          const SizedBox(height: 10),
                          RichText(
                            text: TextSpan(
                              text: "Already have an account?",
                              style: const TextStyle(
                                  fontSize: 16, color: Colors.grey),
                              children: [
                                TextSpan(
                                  text: ' Log in!',
                                  style: const TextStyle(
                                      fontSize: 16,
                                      fontWeight: FontWeight.bold),
                                  recognizer: TapGestureRecognizer()
                                    ..onTap = () {
                                      Navigator.pushReplacement(
                                        context,
                                        LoginView.route(),
                                      );
                                    },
                                ),
                              ],
                            ),
                          )
                        ]))),
              ));
  }
}

As I said before it is a stateful widget, but in the Riverpod version - StatefulConsumerWidget, it can access the providers. If the state of AuthControllerProvider is true it renders the loader. The code for the loader looks like this:

class Loader extends StatelessWidget {
  const Loader({super.key});

  @override
  Widget build(BuildContext context) {
    return const Center(
      child: CircularProgressIndicator(),
    );
  }
}

It is just a centered circular progress indicator. I create a separate class with this because it will be used a lot in the application. The whole view is rather simple. The important fact is wrapping everything in the SingleChildScrollView to avoid problems with the keyboard covering the content. The view has also created a static route, so I can navigate to this view from buttons or from the login screen if the user does not have an account. As you can see the view is also navigating the user to log in view if an account is already created. I won’t show here the code for the login view. It is similar to the sign-up view, it just uses the other method from the controller on clicking the button and navigates the user to another route if an account is created. The views shall look like this in the user interface:

sign_up

log_in

Conclusion

Almost everything for the authentication feature is done. The user can sign in, but the application does not offer anything yet. In the next post, I will implement the user profile feature, so the basic skeleton of the application will be required also. I will implement there the logout button also, which is a part of the authentication feature. Everything will be getting easier to visualize together with the application growth. I hope the content of this post is understandable. Please remember that this is my first bigger mobile application. I am sure some parts could be done better, but I really enjoy the process of creating this project. Cya in the next post!

References

  1. https://dart.academy/immutable-data-patterns-in-dart-and-flutter/
  2. https://codewithandrea.com/articles/flutter-repository-pattern/
  3. https://codewithandrea.com/articles/flutter-app-architecture-application-layer/