Flutter MVVM and Clean Architecture

Welcome to this tutorial on building a mobile application from scratch using MVVM and Clean Architecture.

The Full code example is at : https://github.com/tajaouart/random_user_api_show_case

In this tutorial, we will be using the Random User API as an example to show how to display a list of users from the API and save them locally using SQL. We will be covering the different sections of a project that uses MVVM and Clean Architecture, including the data layer, domain layer, and presentation layer. We will also be discussing how to use various packages and tools to help us in our development process. By the end of this tutorial, you will have a solid understanding of how to build a robust and maintainable mobile application using MVVM and Clean Architecture. So, let's get started!

To start, we need to set up a new Flutter project and add the necessary dependencies.

  1. Create a new Flutter project using the command flutter create
  2. In the pubspec.yaml file, add the following dependencies:
    
    dependencies:
      flutter:
        sdk: flutter
      retrofit:
      dio:
      either_dart:
      cupertino_icons: ^1.0.2
      injectable:
      sqflite:
      freezed_annotation:
      provider:
      get_it:
      path:
    
        
  3. Also, in the pubspec.yaml file, add the following dev_dependencies:
    
    dev_dependencies:
      flutter_test:
        sdk: flutter
      retrofit_generator:
      build_runner:
      flutter_lints: ^2.0.0
      injectable_generator:
      freezed:
    
        
  4. Run flutter packages get to install the dependencies

With this, we have a basic setup for our project and we can start adding the necessary files and classes for our application, using the random_user_api as an example to display some users from the API and save them locally using SQL.

Data Layer

The data layer is responsible for managing the data of the application. It communicates with the API to fetch and send data, as well as with the local storage to save and retrieve data.

API

To communicate with the random user API, we use the retrofit package. This package allows us to easily create a client that communicates with the API using the Dio package. Here is an example of an @RestApi client that communicates with the random user API.


@RestApi(baseUrl: 'https://randomuser.me/api/')
abstract class ApiClient {
  factory ApiClient(Dio dio, {String baseUrl}) = _ApiClient;
  @GET('/')
  Future getUsers(
    @Query('page') int page,
    @Query('results') int results,
  );
}

Local Storage

Local Storage To save and retrieve data from the local storage, we use the sqflite package. This package allows us to easily interact with a SQLite database. Here is an example of a UserDao class that interacts with the local storage to save and retrieve User objects.

class UserDao {
  final Database db;
  UserDao(this.db);

  Future insertUser(User user) async {
    await db.insert(
      'users',
      user.toJson(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future> getAllUsers() async {
    final List
   > maps = await db.query('users'); return List.generate(maps.length, (i) { return User.fromJson(maps[i]); }); } }  

Dependency Management

In order to make the code more maintainable and easy to test, we use the injectable package to manage dependencies.

@module
abstract class RegisterModule {
  @preResolve
  @singleton
  @Named('dataBasePath')
  Future dataBasePath() {
    return getDatabasesPath();
  }
@singleton
@preResolve
@Named('usersDao')
Future usersDao(
@Named('dataBasePath') String databasesPath,
) async {
final db = await openDatabase(
join(databasesPath, 'users_database.db'),
onCreate: (db, version) async {
await db.execute(
'CREATE TABLE users('
'first_name TEXT,'
'last_name TEXT,'
'email TEXT,'
'large_image TEXT,'
'medium_image TEXT,'
'thumbnail TEXT,'
'PRIMARY KEY (email));',
);
},
version: 1,
);
return AppDatabase(db).usersDao;
}
}

Domain Layer

The domain layer is responsible for handling the business logic of the application. It defines the models and the repositories that work with these models. In this example, we have a User model and a UserRepository that handles fetching and storing users. Here's an example of the UserRepository:

@singleton
class UserRepository {
  final ApiClient _userApi;
  final UserDao _usersDao;

  UserRepository(
    @Named('usersDao') this._usersDao,
  ) : _userApi = ApiClient(
          Dio(BaseOptions(contentType: 'application/json')),
        );

  Future> getUsersFromApi({
    required int page,
    required int results,
  }) async {
    try {
      var response = await _userApi
          .getUsers(page, results)
          .timeout(const Duration(seconds: 10));
      if (response.response.statusCode == 200) {
        var jsonResponse = response.data;
        List jsonUsers = jsonResponse['results'];
        final users = jsonUsers.map((user) {
          return User.fromJson(user);
        }).toList();
        _usersDao.insertAllUsers(users);
        return Right(users);
      } else {
        return const Left(AppError.apiFetch);
      }
    } catch (e) {
      debugPrint('$runtimeType error while fetching api users $e');
      return const Left(AppError.apiFetch);
    }
  }

  Future> getUsersFromLocalDb({
    required int page,
    required int results,
  }) async {
    final offset = (page - 1) * results;
    return await _usersDao.getAllUsers(offset: offset, limit: results);
  }
}

This repository uses the UserDao from the Data Layer to store the users in the local database and the ApiClient to fetch the users from the API. It also uses the Either class from the either_dart package to handle errors in a more functional way. The getUsersFromApi method returns an Either which is either an error or a list of users. The getUsersFromLocalDb method returns a list of users from the local database.

Presentation Layer

The Presentation Layer of the application is responsible for handling the user interface and user interactions. In this case, the presentation layer consists of pages and view models. One of the main components in the presentation layer is the UserViewModel, which is a ViewModel that uses the UserRepository to fetch and manage the data for the users. The UserViewModel is a singleton class that holds the state of the view and updates it accordingly. The state of the view is represented by the UserVMState, which is an enumeration that can take on the values of initial, error, loading, and loaded. The UserViewModel has a loadData method that is responsible for fetching the users from the API and the local database and updating the state accordingly. The view model also has a loading getter that is used to check if the view is currently loading data.

Here is an example of how the UserViewModel and UserVMState might be used in a page:


class UsersPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ViewModelBuilder.reactive(
      builder: (context, model, child) {
        return model.loading
            ? const CircularProgressIndicator()
            : ListView.builder(
                itemCount: model.users.length,
                itemBuilder: (context, index) {
                  final user = model.users[index];
                  return ListTile(
                    title: Text(user.name),
                    subtitle: Text(user.email),
                  );
                },
              );
      },
      viewModelBuilder: () => UserViewModel(context.read()),
    );
  }
}

In this example, the UsersPage uses a ViewModelBuilder widget to build the page using the UserViewModel. The ViewModelBuilder widget takes care of creating the view model and disposing of it when the page is no longer needed. The UsersPage displays a CircularProgressIndicator while the view model is loading data and a list of ListTile widgets when the data is loaded. Each ListTile widget displays the name and email of a user.

Conclusion

In conclusion, using the MVVM pattern along with Clean Architecture in Flutter allows for a more organized and maintainable codebase. By separating the concerns of the Data Layer, Domain Layer, and Presentation Layer, and using the ViewModel as a mediator, we can ensure a clear separation of responsibilities and make it easier to test and update the code. Additionally, the use of the Either class from the either_dart package allows for a more functional approach to handling errors, making the code more robust. Overall, implementing MVVM and Clean Architecture in Flutter can greatly improve the development process and lead to a more efficient and scalable application.