14. Local Database

14. Local Database

Hello, Welcome back. Glad that you’re here.

We're building a Movie App with the best coding practices and tools. In the previous tutorial, we worked on the searching movie and with that, we have covered all features that fetch movies from TMDb API.

In this tutorial, we will work on 2 things related to the local database. Mark movies as favorites and show them to the user from the navigation drawer and save the language selected by the user so that when the user reopens the app, the previously marked language is loaded instead of default English. For all of this, we will use the Hive plugin.

The features that we will implement using Hive are:

  1. Insert favorite movie in DB.
  2. Retrieve the list of favorite movies from DB.
  3. Check whether a particular movie is a favorite or not.
  4. Unfavorite the Movie.
  5. Save User Preferred Language
  6. Retrieve Preferred Language

Understand Hive in Clean Architecture

Before we go anywhere in the code, you need to understand where does DB calls fall when following Clean Architecture. In a simple one-liner, I can state that accessing DB is no different than accessing any API, both are considered outside world calls. Our DB CRUD operations will be written in data layer. You'll create a new data source first, i.e. MovieLocalDataSource and LanguageLocalDataSource, former deals with movie database calls, and the latter deals with language database.

Repositories in Data Layer have the same responsibility to fetch data from API or local database. In our case, we will have favorite movie CRUD methods in the existing MovieRepositoryImpl class because those are related to movies. For this, you'll add an instance of MovieLocalDataSource in the MovieRepositoryImpl so that you can call CRUD methods from the repository.

Logically, for language-related methods in repositories, you'll create a new AppRepositoryImpl class that will hold app-related methods.

Coming to Tables, because we will store movie-related data in the hive, we will need to create MovieTable that should be Hive Object. Again as MovieModel, this will extend MovieEntity to maintain a level of abstraction.

These are the only things that I wanted to clear before moving to actual implementation. So, Let's start coding now.

Setup Hive

First, add dependencies for hive and path_provider to connect with hive DB and get the application directory path respectively:

hive: ^1.4.1+1
path_provider: ^1.6.11

Second, initialize the Hive by giving the application documents directory path. Since, we use await to get the path from path_provider, add async also to the main method.

final appDocumentDir = await path_provider.getApplicationDocumentsDirectory();
Hive.init(appDocumentDir.path);

Hive DB Schema

We need to now define the structure in which the data should be stored. As I have previously mentioned, the table also extends entity and some fields can be omitted as we don't want to fill user's phone memory with too much unimportant data that we don't show in UI. This is the UI that we show in the favorite movies list, accordingly, we will create the table class.

//1
part 'movie_table.g.dart';

//2
@HiveType(typeId: 0)
//3
class MovieTable extends MovieEntity {
  //4
  @HiveField(0)
  final int id;
  @HiveField(1)
  final String title;
  @HiveField(2)
  final String posterPath;

  MovieTable({
    this.id,
    this.title,
    this.posterPath,
  }) : super(
          id: id,
          title: title,
          posterPath: posterPath,
        );

}
  1. Give the generated file path here because the hive will generate this file when we run the build runner command.
  2. Declare a class as HiveType and give the typeId as 0. Do not assign the same typeId to another class. As per the doc, unique typeId is used to find the correct adapter when a value is brought back from the disk.
  3. Extend this class by MovieEntity.
  4. Declare all fields with HiveField annotation. The number should be unique per class and should not be changed once used in the app.

Update the dev_dependencies in pubspec.yaml:

hive_generator: 0.7.2
build_runner: ^1.10.0

Now run the build_runner command:

flutter packages pub run build_runner build

You will see the mobile_table.g.dart file at the same location.

Local DB operations (Data Layer)

Our usecases are saving favorite movie, fetching all the favorite movies, Delete Movie from favorite movies and check if movie is favorite or not. So, let's create MovieLocalDataSource:

//1
abstract class MovieLocalDataSource {
  //2
  Future<void> saveMovie(MovieTable movieTable);
  //3
  Future<List<MovieTable>> getMovies();
  //4
  Future<void> deleteMovie(int movieId);
  //5
  Future<bool> checkIfMovieFavorite(int movieId);
}
  1. Create an abstract class, we will implement this class similar to MovieRemoteDataSource.
  2. The saveMovie() will take in the table and save it in the hive.
  3. The getMovies() will return us all the favorite movies.
  4. To unfavorite movie we will create deleteMovie(). This will delete the movie from the favorite table.
  5. The checkIfMovieFavorite() will help us in toggling the favorite icon in the movie detail screen.

Implement the methods

//1
class MovieLocalDataSourceImpl extends MovieLocalDataSource {
  @override
  Future<bool> checkIfMovieFavorite(int movieId) async {
    throw UnimplementedError();
  }

  @override
  Future<void> deleteMovie(int movieId) async {
    throw UnimplementedError();
  }

  @override
  Future<List<MovieTable>> getMovies() async {
    throw UnimplementedError();
  }

  @override
  Future<void> saveMovie(MovieTable movieTable) async {
    throw UnimplementedError();
  }

}
  1. Implement all the methods and add the async keyword.

Save Movie

The first operation that we will apply to the database is to save the movie where you will insert this movie in the database. If a movie is present in the database, it will be considered a favorite.

@override
Future<void> saveMovie(MovieTable movieTable) async {
  //1
  final movieBox = await Hive.openBox('movieBox');
  //2
  await movieBox.put(movieTable.id, movieTable);
}
  1. First, you will open the box. movieBox is an identifier of the box where you will keep the favorite movies.
  2. Once this box is opened, you'll put the movie as value and the movie id as the key.

Get the Movies

After saving the movie, we will fetch all the movies from movieBox and display them to the user.

@override
Future<List<MovieTable>> getMovies() async {
  //1
  final movieBox = await Hive.openBox('movieBox');
  //2
  final movieIds = movieBox.keys;
  //3
  List<MovieTable> movies = [];
  //4
  movieIds.forEach((movieId) {
    movies.add(movieBox.get(movieId));
  });
  //5
  return movies;
}
  1. Again, open the box. If the box is already opened, it will return the opened box.
  2. Get all the keys to the movies in the movie box.
  3. Create an empty array of movies where you will insert the movie one by one.
  4. Loop over every key and get every movie from movieBox and add the movie to the list.
  5. Finally, return to the movies. We will show these movies on the favorite movies list.

Delete Movie

We will mark the movie unfavorite, so let's implement the delete method functionality as well.

//1
final movieBox = await Hive.openBox('movieBox');
//2
await movieBox.delete(movieId);
  1. Open the box.
  2. Delete the movie in the movieBox by movieId.

Check If Movie Is Favorite

In Movie Detail Screen, we need to show whether the movie is a favorite or not, so let's add body to this method as well.

final movieBox = await Hive.openBox('movieBox');
//1
return movieBox.containsKey(movieId);
  1. After opening the box, check whether movieId exists or not. If not, that means the movie is not marked as favorite. If present, that means the movie is marked as favorite.

Repository (Domain Layer)

To perform all these operations, let's create the repository now. Open MovieRepository and add the 4 methods:

//1
final MovieLocalDataSource localDataSource;

//2
MovieRepositoryImpl(this.remoteDataSource, this.localDataSource);

@override
Future<Either<AppError, void>> saveMovie(MovieEntity movieEntity) async {
  try {
    //3
    final response = await localDataSource.saveMovie(MovieTable.fromMovieEntity(movieEntity);
    //4
    return Right(response);
  } on Exception {
    //5
    return Left(AppError(AppErrorType.database));
  } 
}
  1. Before adding the methods, we need to have a reference of MovieLocalDataSource so that we can call the data source methods that we created before.
  2. Along with MovieRemoteDataSource, the repository now depends on MovieLocalDataSource.
  3. In the saveMovie(), under the try block, save the movie. We cannot directly convert the movie entity to the movie table, so I will show the solution in a moment.
  4. On success, we will return the Right object.
  5. On any exception, we will return the Left object. The error would be of a new type i.e. database. So add this to the AppErrorType enum.
enum AppErrorType { api, network, database }

To convert movie entity to movie table, let's create a method fromMovieEntity(movieEntity) in MovieTable class.

//1
factory MovieTable.fromMovieEntity(MovieEntity movieEntity) {
  //2
  return MovieTable(
    id: movieEntity.id,
    title: movieEntity.title,
    posterPath: movieEntity.posterPath,
  );
}
  1. Create the factory method that accepts MovieEntity.
  2. Return the MovieTable instance with all the 3 fields.

After this method, let's work on the other methods too, that should be quick:

@override
Future<Either<AppError, List<MovieEntity>>> getFavoriteMovies() async {
  try {
    //1
    final response = await localDataSource.getMovies();
    return Right(response);
  } on Exception {
    return Left(AppError(AppErrorType.database));
  }
}

@override
Future<Either<AppError, void>> deleteFavoriteMovie(int movieId) async {
  try {
    //2
    final response = await localDataSource.deleteMovie(movieId);
    return Right(response);
  } on Exception {
    return Left(AppError(AppErrorType.database));
  }
}

@override
Future<Either<AppError, bool>> checkIfMovieFavorite(int movieId) async {
  try {
    //3
    final response = await localDataSource.checkIfMovieFavorite(movieId);
    return Right(response);
  } on Exception {
    return Left(AppError(AppErrorType.database));
  }
}
  1. Call the getMovies() in the getFavoriteMovies().
  2. Call the deleteMovie() in the deleteFavoriteMovie().
  3. Call the checkIfMovieFavorite in the checkIfMovieFavorite().

Modify GetIt

When we have made changes to the constructor of MovieRepository, we have to update the get_it declaration:

//1
getItInstance
  .registerLazySingleton<MovieRepository>(() => MovieRepositoryImpl(
        getItInstance(),
        getItInstance(),
      ));

//2
getItInstance.registerLazySingleton<MovieLocalDataSource>(
    () => MovieLocalDataSourceImpl());
  1. Add an instance of MovieLocalDataSource as the second parameter of MovieRepository.
  2. You will also register MovieLocalDataSource similar to MovieRemoteDataSource.

Usecases

To access these repository methods from UI or Blocs, we will create usecases. Let's create 4 usecases. First, saveMovie usecase:

Save Movie Usecase

Create a file save_movie.dart in the usecase folder:

//1
class SaveMovie extends UseCase<void, MovieEntity> {
  final MovieRepository movieRepository;

  SaveMovie(this.movieRepository);

  @override
  Future<Either<AppError, void>> call(MovieEntity params) async {
    return await movieRepository.saveMovie(params);
  }
}
  1. As explained in previous instances, when I showed creating a usecase, you need to remember 2 things, input, and output. Here, we need a movie while saving and nothing as output.

Similarly, add other usecases. I don't think any explanation is required there. In case of any doubts, please join in https://discord.gg/Q5GfbZsvPk Techie Blossom Discord Server and post your queries there.

Get Favorite Movies Usecase

Create another usecase in get_favorite_movies.dart file:

//1
class GetFavoriteMovies extends UseCase<List<MovieEntity>, NoParams> {
  final MovieRepository movieRepository;

  GetFavoriteMovies(this.movieRepository);

  @override
  Future<Either<AppError, List<MovieEntity>>> call(NoParams noParams) async {
    return await movieRepository.getFavoriteMovies();
  }
}
  1. Here, inputs are NoParams because we are only fetching all the favorite movies. List<MovieEntity> becomes the output here.

Delete Favorite Movie Usecase

Create third usecase in delete_favorite_movie.dart file:

//1
class DeleteFavoriteMovie extends UseCase<void, MovieParams> {
  final MovieRepository movieRepository;

  DeleteFavoriteMovie(this.movieRepository);

  @override
  Future<Either<AppError, void>> call(MovieParams movieParams) async {
    return await movieRepository.deleteFavoriteMovie(movieParams.id);
  }
}
  1. To delete a favorite movie, we can delete it by movie id because we have stored it by movie id. Again the output of that will be void.

Check if Favorite Movie Usecase

Create last usecase in check_if_movie_favorite.dart file:

//1
class CheckIfFavoriteMovie extends UseCase<bool, MovieParams> {
  final MovieRepository movieRepository;

  CheckIfFavoriteMovie(this.movieRepository);

  @override
  Future<Either<AppError, bool>> call(MovieParams movieParams) async {
    return await movieRepository.checkIfMovieFavorite(movieParams.id);
  }
}
  1. Here input will be MovieParams because we want to check whether a movie is in the database or not by movie id. The output will be true or false, so keep the type as bool.

When all the usecases are in place, let's put them in GetIt, so that we can move to Blocs now.

getItInstance
      .registerLazySingleton<SaveMovie>(() => SaveMovie(getItInstance()));

getItInstance.registerLazySingleton<GetFavoriteMovies>(
    () => GetFavoriteMovies(getItInstance()));

getItInstance.registerLazySingleton<DeleteFavoriteMovie>(
    () => DeleteFavoriteMovie(getItInstance()));

getItInstance.registerLazySingleton<CheckIfFavoriteMovie>(
    () => CheckIfFavoriteMovie(getItInstance()));

All usecases have MovieRepository in the constructor, so use getItInstance() which will help in resolving the instance of MovieRepository.

Favorite Bloc

After we are done with the data and domain layers, it's time to move to the presentation layer. If you have followed my previous tutorials in this series, you know that the presentation layer doesn't contain only widgets, it also contains Blocs.

Events

Create a new bloc in bloc folder and in favorite_event.dart, add the events.

//1
class LoadFavoriteMovieEvent extends FavoriteEvent {
  @override
  List<Object> get props => [];
}

//2
class DeleteFavoriteMovieEvent extends FavoriteEvent {
  final int movieId;

  DeleteFavoriteMovieEvent(this.movieId);

  @override
  List<Object> get props => [movieId];
}

//3
class ToggleFavoriteMovieEvent extends FavoriteEvent {
  final MovieEntity movieEntity;
  final bool isFavorite;

  ToggleFavoriteMovieEvent(this.movieEntity, this.isFavorite);

  @override
  List<Object> get props => [movieEntity, isFavorite];
}

//4
class CheckIfFavoriteMovieEvent extends FavoriteEvent {
  final int movieId;

  CheckIfFavoriteMovieEvent(this.movieId);

  @override
  List<Object> get props => [movieId];
}
  1. We will show the favorite movies list, so create LoadFavoriteMovieEvent.
  2. On press of delete icon on the favorite movie card, we will call this event. This will use movieId to delete the movie from DB.
  3. Now, we will create an event that will be used in the movie detail screen when the user pressed the filled or empty favorite icon. It will work like a toggle, for example, if the movie is a favorite and the user presses the icon, it will unfavorite the movie and the reverse happens when the movie is not a favorite. This event will take in MovieEntity and a flag of whether the movie is a favorite or not.
  4. the Last event will be CheckIfFavoriteMovieEvent which will take in movieId and check if the movie is a favorite or not. This will be called when in movie detail screen, we have to either show the filled favorite icon or empty favorite icon.

By the 4 events, you should have understood that events are not created by keeping usecases in mind, but by keeping the user events or actions in mind.

States

It doesn't matter that one event has one state mapped. We can have a lesser number of states as well. Create the states in favorite_state.dart keeping the UI in mind at various stages.

//1
class FavoriteMoviesLoaded extends FavoriteState {
  final List<MovieEntity> movies;

  FavoriteMoviesLoaded(this.movies);

  @override
  List<Object> get props => [movies];
}

//2
class FavoriteMoviesError extends FavoriteState {}

//3
class IsFavoriteMovie extends FavoriteState {
  final bool isMovieFavorite;

  IsFavoriteMovie(this.isMovieFavorite);

  @override
  List<Object> get props => [isMovieFavorite];
}
  1. When you want to show all the favorite movies, you will yield the FavoriteMoviesLoaded with the list of movies.
  2. When there is an error in any of the operations that we perform, we will yield FavoriteMoviesError.
  3. When we are done with checking whether the movie is a favorite or not, we will return the IsFavoriteMovie state with a boolean flag, which will tell us if the movie is a favorite or not. Based on this, we can show the icon as filled or empty.

Logic

Now we will write the exact logic that bloc should handle instead of widgets. We know that we will call usecases here so declare all the usecases as the required fields.

class FavoriteBloc extends Bloc<FavoriteEvent, FavoriteState> {
  //1
  final SaveMovie saveMovie;
  final GetFavoriteMovies getFavoriteMovies;
  final DeleteFavoriteMovie deleteFavoriteMovie;
  final CheckIfFavoriteMovie checkIfFavoriteMovie;

  FavoriteBloc({
    @required this.saveMovie,
    @required this.getFavoriteMovies,
    @required this.deleteFavoriteMovie,
    @required this.checkIfFavoriteMovie,
  }) : super(FavoriteInitial());

  @override
  Stream<FavoriteState> mapEventToState(
    FavoriteEvent event,
  ) async* {
    //Handle events here...
  }
}
  1. Declare all the usecases in the constructor and mark them @required.

Now, handle the events in mapEventToState():

@override
Stream<FavoriteState> mapEventToState(
  FavoriteEvent event,
) async* {
  //1
  if (event is ToggleFavoriteMovieEvent) {
    //2
    if (event.isFavorite) {
      await deleteFavoriteMovie(MovieParams(event.movieEntity.id));
    } else {
      await saveMovie(event.movieEntity);
    }
    //3
    final response =
        await checkIfFavoriteMovie(MovieParams(event.movieEntity.id));
    yield response.fold(
      (l) => FavoriteMoviesError(),
      (r) => IsFavoriteMovie(r),
    );
  } 
  //4
  else if (event is LoadFavoriteMovieEvent) {
    yield* _fetchLoadFavoriteMovies();
  } 
  //5
  else if (event is DeleteFavoriteMovieEvent) {
    await deleteFavoriteMovie(MovieParams(event.movieId));
    yield* _fetchLoadFavoriteMovies();
  } 
  //6
  else if (event is CheckIfFavoriteMovieEvent) {
    final response = await checkIfFavoriteMovie(MovieParams(event.movieId));
    yield response.fold(
      (l) => FavoriteMoviesError(),
      (r) => IsFavoriteMovie(r),
    );
  }
}

//4
Stream<FavoriteState> _fetchLoadFavoriteMovies() async* {
  final Either<AppError, List<MovieEntity>> response =
      await getFavoriteMovies(NoParams());

  yield response.fold(
    (l) => FavoriteMoviesError(),
    (r) => FavoriteMoviesLoaded(r),
  );
}
  1. Handle the ToggleFavoriteMovieEvent.
  2. Check if the movie is a favorite or not. If the movie is a favorite, remove it from the favorite list in the DB.
  3. If the movie is not a favorite, mark it as a favorite. Using the fold operator, yield the error state for left and success state for right.
  4. We will create a method to handle when the event is LoadFavoriteMovieEvent. Here, you can call the getFavoriteMovies usecase and handle the response using the fold operator.
  5. If the event is DeleteFavoriteMovieEvent, delete the movie from DB.
  6. If the event is CheckIfFavoriteMovieEvent, call the checkIfFavoriteMovie usecase and yield the state using the fold operator.

Register the Bloc

In GetIt, declare the bloc initialisation:

//1
getItInstance.registerFactory(() => FavoriteBloc(
  saveMovie: getItInstance(),
  checkIfFavoriteMovie: getItInstance(),
  deleteFavoriteMovie: getItInstance(),
  getFavoriteMovies: getItInstance(),
));
  1. Declare the FavoriteBloc as factory and give the four required usecases.

UI

Let's start with saving the movie. In the movie detail screen, we need to show a filled or empty favorite icon, so for that, we need FavoriteBloc in MovieDetailBloc. Let's do that:

Update the toggle icon

Update MovieDetailBloc:

//1
final FavoriteBloc favoriteBloc;

MovieDetailBloc({
    @required this.getMovieDetail,
    @required this.castBloc,
    @required this.videosBloc,
    @required this.favoriteBloc,
}) : super(MovieDetailInitial());
  1. Declare the bloc as the final field and make it required.

Next, open get_it.dart and update the dependency of MovieDetailBloc

Last, when the movie details are loaded, emit CheckIfFavoriteMovieEvent.

favoriteBloc.add(CheckIfFavoriteMovieEvent(event.movieId));

To update the UI for favorite bloc state, let's declare the favoriteBloc in MovieDetailScreen:

//1
FavoriteBloc _favoriteBloc;

//2
_favoriteBloc = _movieDetailBloc.favoriteBloc;

//3
_favoriteBloc?.close();

//4
BlocProvider.value(value: _favoriteBloc),
  1. Declare the bloc.
  2. Get the instance in initState() from movieDetailBloc.
  3. In dispose(), close the _favoriteBloc.
  4. In the MultiBlocProvider, add the _favoriteBloc.

Open MovieDetailAppBar and listen for the states of FavoriteBloc.

//1
BlocBuilder<FavoriteBloc, FavoriteState>(
  builder: (context, state) {
    //2
    if (state is IsFavoriteMovie) {
      return Icon(
        state.isMovieFavorite ? Icons.favorite : Icons.favorite_border,
        color: Colors.white,
        size: Sizes.dimen_12.h,
      );
    } 
    //3
    else {
      return Icon(
        Icons.favorite_border,
        color: Colors.white,
        size: Sizes.dimen_12.h,
      );
    }
  },
),
  1. Wrap the favorite_border icon with BlocBuilder.
  2. When the state is IsFavoriteMovie, you can get the boolean value and decide which icon to show.
  3. Since, the builder has to return with one widget, put the default icon i.e. border icon.

Now, wrap the Icon with GestureDetector to fire an event to save the movie when we tap the favorite icon.

//1
GestureDetector(
  onTap: () {
    //2
    _favoriteBloc.add(
      ToggleFavoriteMovieEvent(
        //4
        MovieEntity.fromMovieDetailEntity(
          movieDetail,
        ),
        //6
        state.isMovieFavorite,
      ),
    );
  }
),

//3
final MovieDetailEntity movieDetailEntity;

const MovieDetailAppBar({
  Key key,
  @required this.movieDetailEntity,
}) : super(key: key);

//5
factory MovieEntity.fromMovieDetailEntity(
    MovieDetailEntity movieDetailEntity) {
  return MovieEntity(
    posterPath: movieDetailEntity.posterPath,
    id: movieDetailEntity.id,
    backdropPath: movieDetailEntity.backdropPath,
    title: movieDetailEntity.title,
    voteAverage: movieDetailEntity.voteAverage,
    releaseDate: movieDetailEntity.releaseDate,
  );
}
  1. Use GestureDetector to handle tap events.
  2. In the onTap(), dispatch ToggleFavoriteMovieEvent.
  3. To save movies in local DB, we need a movie entity to pass it from BigPoster and make MovieDetailAppBar able to use the movie detail entity. We will later convert the movie detail entity to the movie entity.
  4. This event needs an instance of MovieEntity and a true or false value stating whether the movie is a favorite or not.
  5. Create a factory method that converts the movie detail entity to the movie entity. Use this as the first parameter for the ToggleFavoriteMovieEvent.
  6. The second parameter of the event will be taken from the state.

Run the app now. Check the debug console and there is an error. This error is because we forgot to add the super in MovieTable. Let's add that and re-run the app:

MovieTable({
  this.id,
  this.title,
  this.posterPath,
})
//1
 : super(
        id: id,
        title: title,
        posterPath: posterPath,
        backdropPath: '',
        releaseDate: '',
        voteAverage: 0,
      );
  1. 3 fields are important so, assign values to them. Rest all can be empty or default values. These will not be saved in the local DB. Only id, title, and posterPath will be saved.

Finally, run the app and check what we have done till now in this tutorial. You can mark and unmark the movie as a favorite.

Let's work on listing down all the favorite movies now.

Favorite Movies List

If you want to have a screen where you can see all the favorite movies, then you can press on the Favorite Movies menu item in the navigation drawer. Let's create a screen for the same and link it with the navigation drawer.

Open

NavigationListItem(
  title: TranslationConstants.favoriteMovies.t(context),
  //1
  onPressed: () {
    Navigator.of(context).push(
      MaterialPageRoute(
        builder: (context) => FavoriteScreen(),
      ),
    );
  },
)
  1. Open a new screen, FavoriteScreen.

Now, create a new folder favorite in journeys because this is a new journey. Create a new widget in this folder - favorite_screen.dart:

class FavoriteScreen extends StatelessWidget {

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

We will need a favorite bloc here so make this one Stateful widget.

//1
FavoriteBloc _favoriteBloc;

_favoriteBloc = getItInstance<FavoriteBloc>();

//2
_favoriteBloc?.close();

//3
@override
Widget build(BuildContext context) {
  return Scaffold(
    appBar: AppBar(
      title: Text(
        TranslationConstants.favoriteMovies.t(context),
      ),
    ),
  );
}
  1. Declare the variable in the state class for FavoriteBloc and initialize it in initState().
  2. Close the bloc in dispose().
  3. In the build(), use Scaffold with AppBar and the same title as that in the navigation drawer tile.

Run the app and now you'll see the title on the screen.

Favorite Movie Grid and Movie Card

In the favorite screen, we will use BlocProvider and BlocBuilder to display different stages of UI, like no favorite movies and grid of favorite movies. We also need to fire an LoadFavoriteMovieEvent in initState()

//5
_favoriteBloc.add(LoadFavoriteMovieEvent());

//1
body: BlocProvider.value(
  value: _favoriteBloc,
  //2
  child: BlocBuilder<FavoriteBloc, FavoriteState>(
    builder: (context, state) {
      //3
      if (state is FavoriteMoviesLoaded) {
        if (state.movies.isEmpty) {
          return Center(
            child: Text(
              TranslationConstants.noFavoriteMovie.t(context),
              textAlign: TextAlign.center,
              style: Theme.of(context).textTheme.subtitle1,
            ),
          );
        }
        return const SizedBox.shrink();
      }
      return const SizedBox.shrink();
    },
  ),
)
  1. Since, the instance of FavoriteBloc is not being provided above, we need to provide it on this screen itself.
  2. Use the BlocBuilder to read the states.
  3. If and only if the state is FavoriteMoviesLoaded, we will display something otherwise, we will return empty sized box. In this state, we will have 2 outputs, an empty list or a filled list. If we have an empty list, we will show a text in the center. We will return empty sized box when the movies list is not empty for now.

Create a new widget FavoriteMovieGridWidget and call it when the list of favorite movies is not empty.

class FavoriteMovieGridView extends StatelessWidget {
  //1
  final List<MovieEntity> movies;

  const FavoriteMovieGridView({
    Key key,
    @required this.movies,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}
  1. This widget will accept a list of movies as required fields.

Build the UI in the build():

@override
Widget build(BuildContext context) {
  //1
  return Padding(
    padding: EdgeInsets.symmetric(horizontal: Sizes.dimen_8.w),
    //2
    child: GridView.builder(
      shrinkWrap: true,
      itemCount: movies.length,
      //3
      gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
        crossAxisCount: 2,
        childAspectRatio: 0.7,
        crossAxisSpacing: Sizes.dimen_16.w,
      ),
      //4
      itemBuilder: (context, index) {
        return FavoriteMovieCardWidget(
          movie: movies[index],
        );
      },
    ),
  );
}
  1. We need horizontal spacing for the grid view, so use Padding.
  2. Use the builder of GridView to build a grid for favorite movies.
  3. In the gridDelegate, use the fixed cross-axis count parameter with 2 items in one row with sufficient spacing in between.
  4. Lastly, return FavoriteMovieCardWidget for each item.

Now, create a new file having FavoriteMovieCardWidget widget:

class FavoriteMovieCardWidget extends StatelessWidget {
  //1
  final MovieEntity movie;

  const FavoriteMovieCardWidget({
    Key key,
    @required this.movie,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    //2
    return Container(
      margin: EdgeInsets.only(bottom: Sizes.dimen_8.h),
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(Sizes.dimen_8.w),
      ),
      //7
      child: GestureDetector(
        onTap: () {
          Navigator.of(context).push(
            MaterialPageRoute(
              builder: (context) => MovieDetailScreen(
                movieDetailArguments: MovieDetailArguments(movie.id),
              ),
            ),
          );
        },
        //6
        child: ClipRRect(
          borderRadius: BorderRadius.circular(Sizes.dimen_8.w),
          //3
          child: Stack(
            children: <Widget>[
              //4
              CachedNetworkImage(
                imageUrl: '${ApiConstants.BASE_IMAGE_URL}${movie.posterPath}',
                fit: BoxFit.cover,
                width: Sizes.dimen_100.h,
              ),
              //5
              Align(
                alignment: Alignment.topRight,
                child: GestureDetector(
                  onTap: () => BlocProvider.of<FavoriteBloc>(context)
                      .add(DeleteFavoriteMovieEvent(movie.id)),
                  child: Padding(
                    padding: EdgeInsets.all(Sizes.dimen_12.w),
                    child: Icon(
                      Icons.delete,
                      size: Sizes.dimen_12.h,
                      color: Colors.white,
                    ),
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
  1. This widget will take the movie as the required field.
  2. In the build(), we will give some margin and rounded corners.
  3. Use Stack to position the image below the delete icon.
  4. First child will be the poster image with a width of 100 and will always follow the aspect ratio.
  5. Next, we need a delete icon on top. On tap of this icon, we will delete the movie from the favorite list. So, fire DeleteFavoriteMovieEvent on the favorite bloc to delete the movie.
  6. Clip the Stack so that we can see the round edges of the Container above.
  7. When we tap on the card, we must navigate to the movie detail screen. So use GestureDetector.

We are done with our favorite movie features completely. Let's start with the language now.

Save Preferred Language In Local DB

Till now we only have the user's preferred language as app-specific data, so once the user closes the app and re-open the app the preferred language is not persisted. Today, we have language, tomorrow we can have other such things. So, we will create a new repository that will deal with this type of data. Name is as app_repository.dart in domain layer.

//1
abstract class AppRepository {
  //2
  Future<Either<AppError, void>> updateLanguage(String language);
  Future<Either<AppError, String>> getPreferredLanguage();
}
  1. Declare an abstract class because, like MovieRepository, this will also have the implementation in the data layer.
  2. We need 2 functionalities, save the language in local DB and fetch the saved language.

Create the implementation file in data/repositories now:

class AppRepositoryImpl extends AppRepository {

  @override
  Future<Either<AppError, String>> getPreferredLanguage() {
    throw UnimplementedError();
  }

  @override
  Future<Either<AppError, void>> updateLanguage(String language) {
    throw UnimplementedError();
  }

}

Before adding any functionality to it, let's head on to the data source for language.

Create a new file language_local_data_source.dart:

//1
abstract class LanguageLocalDataSource {
  Future<void> updateLanguage(String languageCode);
  Future<String> getPreferredLanguage();
}

//2
class LanguageLocalDataSourceImpl extends LanguageLocalDataSource {
  @override
  Future<String> getPreferredLanguage() {
    throw UnimplementedError();
  }

  @override
  Future<void> updateLanguage(String languageCode) {
    throw UnimplementedError();
  }
}
  1. Start with the abstract class and have the similar 2 methods for 2 functionalities.
  2. Create an implementation of the same and override the 2 methods.

Let's now work on saving language to local DB and getting back language from local DB.

//1
@override
Future<void> updateLanguage(String languageCode) async {
  final languageBox = await Hive.openBox('languageBox');
  unawaited(languageBox.put('preferred_language', languageCode));
}

//2
@override
Future<String> getPreferredLanguage() async {
  final languageBox = await Hive.openBox('languageBox');
  return languageBox.get('preferred_language');
}
  1. First, open the box and save the language code in the preferred_language key. We are using unawaited because we need not wait for this operation.
  2. In the second method as well, fetch the language from the same box and same key.

Now update the repository implementation by making use of these methods.

//1
final LanguageLocalDataSource languageLocalDataSource;

AppRepositoryImpl(this.languageLocalDataSource);

//2
@override
Future<Either<AppError, String>> getPreferredLanguage() async {
  try {
    final response = await languageLocalDataSource.getPreferredLanguage();
    return Right(response);
  } on Exception {
    return Left(AppError(AppErrorType.database));
  }
}

//3
@override
Future<Either<AppError, void>> updateLanguage(String language) async {
  try {
    final response = await languageLocalDataSource.updateLanguage(language);
    return Right(response);
  } on Exception {
    return Left(AppError(AppErrorType.database));
  }
}
  1. Declare the variable for LanguageLocalDataSource and pass it in the constructor.
  2. Fetch the preferred language by using the getPreferredLanguage() from the local data source. Use the catch block to return the database type error.
  3. In the next method, update the language by calling updateLanguage() in the same fashion.

The last thing before we make these calls in the bloc is making usecases for these 2.

Create UpdateLanguage useacase:

//1
class UpdateLanguage extends UseCase<void, String> {
  //2
  final AppRepository appRepository;

  UpdateLanguage(this.appRepository);

  @override
  Future<Either<AppError, void>> call(String languageCode) async {
    return await appRepository.updateLanguage(languageCode);
  }
}
  1. This usecase takes in String and returns with nothing, fire and forget.
  2. Declare the instance of AppRepository so that you can call the updateLanguage().

Create GetPreferredLanguage usecase now:

//1
class GetPreferredLanguage extends UseCase<String, NoParams> {
  final AppRepository appRepository;

  GetPreferredLanguage(this.appRepository);

  @override
  Future<Either<AppError, String>> call(NoParams params) async {
    return await appRepository.getPreferredLanguage();
  }
}
  1. This usecase takes no params and returns the language code as String.

Dependency Injection

We have a new repository, data source, and usecase. Let's put them in GetIt.

//1
getItInstance.registerLazySingleton<AppRepository>(() => AppRepositoryImpl(
  getItInstance()));

//2
getItInstance.registerLazySingleton<LanguageLocalDataSource>(
  () => LanguageLocalDataSourceImpl());

//3
getItInstance.registerLazySingleton<UpdateLanguage>(
  () => UpdateLanguage(getItInstance()));

getItInstance.registerLazySingleton<GetPreferredLanguage>(
  () => GetPreferredLanguage(getItInstance()));
  1. Declare AppRepository with an instance of LanguageLocalDataSource.
  2. Declare LanguageLocalDataSource like others.
  3. Now declare 2 usecases with an instance of AppRepository.

We need some improvements in the LanguageBloc:

//1
class LanguageBloc extends Bloc<LanguageEvent, LanguageState> {
  final GetPreferredLanguage getPreferredLanguage;
  final UpdateLanguage updateLanguage;

  LanguageBloc({
    @required this.getPreferredLanguage,
    @required this.updateLanguage,
  }) : super(
          LanguageLoaded(
            Locale(Languages.languages[0].code),
          ),
        );

  @override
  Stream<LanguageState> mapEventToState(
    LanguageEvent event,
  ) async* {
    //2
    if (event is ToggleLanguageEvent) {
      await updateLanguage(event.language.code);
      add(LoadPreferredLanguageEvent());
    } 
    //3
    else if (event is LoadPreferredLanguageEvent) {
      final response = await getPreferredLanguage(NoParams());
      yield response.fold(
        (l) => LanguageError(),
        (r) => LanguageLoaded(Locale(r)),
      );
    }
  }
}
  1. Add the 2 usecases so that we can call them based on the event. Mark them required also.
  2. Handle the ToggleLanguageEvent and update the language. After this call, you will load the preferred language.
  3. When the event is LoadPreferredLanguageEvent, call the getPreferredLanguage() and yield the state based on success or failure.

Create the LoadPreferredLanguageEvent in languge_event.dart:

class LoadPreferredLanguageEvent extends LanguageEvent {}

At last, invoke this event on language bloc in MovieApp:

_languageBloc.add(LoadPreferredLanguageEvent());
  1. Call this in the initState(), so that this is the first call when we land on the application.

Now, before running you will also have to update the declaration of language bloc in GetIt:

getItInstance.registerSingleton<LanguageBloc>(
  LanguageBloc(
    getPreferredLanguage: getItInstance(),
    updateLanguage: getItInstance(),
  ),
);

Run the app after stopping. Select the Spanish language and stop the app. Again open the app, you'll see the text is in the Spanish language. This is what we have achieved by saving the language.

By this, we have come to an end for this tutorial. In the next tutorial, we will work on the search feature. I hope you have learned the local database in this tutorial. See you in the next tutorial. Thanks for reading.

Did you find this article valuable?

Support Prateek Sharma by becoming a sponsor. Any amount is appreciated!