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:
- Insert favorite movie in DB.
- Retrieve the list of favorite movies from DB.
- Check whether a particular movie is a favorite or not.
- Unfavorite the Movie.
- Save User Preferred Language
- 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,
);
}
- Give the generated file path here because the hive will generate this file when we run the build runner command.
- Declare a class as
HiveType
and give thetypeId
as 0. Do not assign the same typeId to another class. As per the doc, uniquetypeId
is used to find the correct adapter when a value is brought back from the disk. - Extend this class by
MovieEntity
. - 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);
}
- Create an abstract class, we will implement this class similar to
MovieRemoteDataSource
. - The
saveMovie()
will take in the table and save it in the hive. - The
getMovies()
will return us all the favorite movies. - To unfavorite movie we will create
deleteMovie()
. This will delete the movie from the favorite table. - 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();
}
}
- 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);
}
- First, you will open the box.
movieBox
is an identifier of the box where you will keep the favorite movies. - 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;
}
- Again, open the box. If the box is already opened, it will return the opened box.
- Get all the keys to the movies in the movie box.
- Create an empty array of movies where you will insert the movie one by one.
- Loop over every key and get every movie from movieBox and add the movie to the list.
- 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);
- Open the box.
- Delete the movie in the
movieBox
bymovieId
.
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);
- 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));
}
}
- 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. - Along with
MovieRemoteDataSource
, the repository now depends onMovieLocalDataSource
. - In the
saveMovie()
, under thetry
block, save the movie. We cannot directly convert the movie entity to the movie table, so I will show the solution in a moment. - On success, we will return the
Right
object. - On any exception, we will return the
Left
object. The error would be of a new type i.e.database
. So add this to theAppErrorType
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,
);
}
- Create the
factory
method that acceptsMovieEntity
. - 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));
}
}
- Call the
getMovies()
in thegetFavoriteMovies()
. - Call the
deleteMovie()
in thedeleteFavoriteMovie()
. - Call the
checkIfMovieFavorite
in thecheckIfMovieFavorite()
.
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());
- Add an instance of
MovieLocalDataSource
as the second parameter ofMovieRepository
. - You will also register
MovieLocalDataSource
similar toMovieRemoteDataSource
.
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);
}
}
- 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();
}
}
- 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);
}
}
- 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);
}
}
- 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 betrue
orfalse
, so keep the type asbool
.
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];
}
- We will show the favorite movies list, so create
LoadFavoriteMovieEvent
. - 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. - 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. - the Last event will be
CheckIfFavoriteMovieEvent
which will take inmovieId
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];
}
- When you want to show all the favorite movies, you will yield the
FavoriteMoviesLoaded
with the list of movies. - When there is an error in any of the operations that we perform, we will yield
FavoriteMoviesError
. - 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...
}
}
- 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),
);
}
- Handle the
ToggleFavoriteMovieEvent
. - Check if the movie is a favorite or not. If the movie is a favorite, remove it from the favorite list in the DB.
- 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. - We will create a method to handle when the event is
LoadFavoriteMovieEvent
. Here, you can call thegetFavoriteMovies
usecase and handle the response using thefold
operator. - If the event is
DeleteFavoriteMovieEvent
, delete the movie from DB. - If the event is
CheckIfFavoriteMovieEvent
, call thecheckIfFavoriteMovie
usecase and yield the state using thefold
operator.
Register the Bloc
In GetIt, declare the bloc initialisation:
//1
getItInstance.registerFactory(() => FavoriteBloc(
saveMovie: getItInstance(),
checkIfFavoriteMovie: getItInstance(),
deleteFavoriteMovie: getItInstance(),
getFavoriteMovies: getItInstance(),
));
- 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());
- 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),
- Declare the bloc.
- Get the instance in
initState()
frommovieDetailBloc
. - In
dispose()
, close the_favoriteBloc
. - 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,
);
}
},
),
- Wrap the
favorite_border
icon withBlocBuilder
. - When the state is
IsFavoriteMovie
, you can get the boolean value and decide which icon to show. - 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,
);
}
- Use
GestureDetector
to handle tap events. - In the
onTap()
, dispatchToggleFavoriteMovieEvent
. - To save movies in local DB, we need a movie entity to pass it from
BigPoster
and makeMovieDetailAppBar
able to use the movie detail entity. We will later convert the movie detail entity to the movie entity. - This event needs an instance of
MovieEntity
and atrue
orfalse
value stating whether the movie is a favorite or not. - Create a
factory
method that converts the movie detail entity to the movie entity. Use this as the first parameter for theToggleFavoriteMovieEvent
. - 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,
);
- 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
, andposterPath
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(),
),
);
},
)
- 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),
),
),
);
}
- Declare the variable in the state class for
FavoriteBloc
and initialize it ininitState()
. - Close the bloc in
dispose()
. - In the
build()
, useScaffold
withAppBar
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();
},
),
)
- Since, the instance of
FavoriteBloc
is not being provided above, we need to provide it on this screen itself. - Use the
BlocBuilder
to read the states. - 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();
}
}
- 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],
);
},
),
);
}
- We need horizontal spacing for the grid view, so use
Padding
. - Use the
builder
ofGridView
to build a grid for favorite movies. - In the
gridDelegate
, use the fixed cross-axis count parameter with 2 items in one row with sufficient spacing in between. - 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,
),
),
),
),
],
),
),
),
);
}
}
- This widget will take the movie as the
required
field. - In the
build()
, we will give some margin and rounded corners. - Use
Stack
to position the image below the delete icon. - First child will be the poster image with a width of 100 and will always follow the aspect ratio.
- 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. - Clip the
Stack
so that we can see the round edges of the Container above. - 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();
}
- Declare an abstract class because, like
MovieRepository
, this will also have the implementation in the data layer. - 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();
}
}
- Start with the abstract class and have the similar 2 methods for 2 functionalities.
- 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');
}
- 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. - 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));
}
}
- Declare the variable for
LanguageLocalDataSource
and pass it in the constructor. - Fetch the preferred language by using the
getPreferredLanguage()
from the local data source. Use thecatch
block to return the database type error. - 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);
}
}
- This usecase takes in
String
and returns with nothing, fire and forget. - Declare the instance of
AppRepository
so that you can call theupdateLanguage()
.
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();
}
}
- 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()));
- Declare
AppRepository
with an instance ofLanguageLocalDataSource
. - Declare
LanguageLocalDataSource
like others. - 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)),
);
}
}
}
- Add the 2 usecases so that we can call them based on the event. Mark them required also.
- Handle the
ToggleLanguageEvent
and update the language. After this call, you will load the preferred language. - When the event is
LoadPreferredLanguageEvent
, call thegetPreferredLanguage()
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());
- 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.