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 a cast list and tutorials list for a movie.
In this tutorial, we will work on searching for a movie with the name.
In all the previous tutorials, we didn't encounter a scenario where we have to send some parameters in the GET query as query parameters or the request payload. But, to search movie by name, we need to send the query string as a query parameter named query. The complete URL would be this - https://api.themoviedb.org/3/search/movie?api_key=API_KEY&query=avenger.
This suggests that we need to alter the existing code of get()
in ApiClient. Let's make this method handle the parameters also if passed.
Update
get()
in ApiClient:
//1
dynamic get(String path, {Map<dynamic, dynamic> params}) async {
final response = await _client.get(
//2
getPath(path, params),
//...
//...
);
//...
//...
}
String getPath(String path, Map<dynamic, dynamic> params) {
//3
var paramsString = '';
//4
if (params?.isNotEmpty ?? false) {
//5
params.forEach((key, value) {
//6
paramsString += '&$key=$value';
});
}
//7
return '${ApiConstants.BASE_URL}$path?api_key=${ApiConstants.API_KEY}$paramsString';
}
- As optional parameters, add a map to the
get()
. This will not break existing get calls and will enhance the functionality. - Instead of directly passing the resultant query string, create a method that will also consider using the parameters if passed.
- In the
getPath()
, define an empty string so that an empty string is used when params are not passed and existing functionality works as before. - If and only if params are there, that means if params are not empty, we will proceed with creating a string for query parameters, otherwise not. The params can be passed as null, so handle that by using the
??
operator. - With this we are not only handling one param but N number of params. This is how you create generic methods.
- Append the key and value in the
paramString
. - Now append the params string also after appending
API_KEY
.
We are done with the core of searching, let's quickly follow the basic steps as discussed in the previous tutorial.
- Domain Layer - Request & Response Model, Method in abstract repository and usecase
- Data Layer - Model, Repository Implementation, and Datasource
- Presentation Layer - Add a new Bloc
- Dependency Injection
I will not repeat the steps here but because this is a new type of API call that we are making, I will specify the differences.
The request entity class will be a new one - MovieSearchParams
, that will contain the query term entered by the user. As a result, the definition of the SearchMovies
usecase will also change. Rest all remain the same. You can always refer to the GitHub repository.
After the above 4 steps, we have ready with bloc, domain, and data layer and dependency injection. Now, we are only left with UI designing and emitting the event. So, let's start designing.
Register Bloc
If you remember, from the 5th part - Home Screen Carousel(), we have the search icon in the top part of the home screen. So, let's add the SearchMovieBloc
in the MultiBlocProvider
:
//1
searchMovieBloc = getItInstance<SearchMovieBloc>();
//2
searchMovieBloc?.close();
//3
BlocProvider(
create: (_) => searchMovieBloc,
),
- Get the instance of
SearchMovieBloc
below the other blocs inHomeScreen
. - Dispose of the bloc like others.
- Use the bloc in the
BlocProvider
.
Open the Search Screen
In the MovieAppBar
, replace the empty body of onPressed()
with call to showSearch()
that is present in the Flutter framework.
showSearch(
//1
context: context,
//2
delegate: CustomSearchDelegate(
BlocProvider.of<SearchMovieBloc>(context),
),
);
- This method needs
context
. - It also needs a
delegate
that can create a new Scaffold screen for you on your behalf. The default implementationSearchDelegate
consists of basic fields like search label, field, etc. But, to create custom searches, we need to create a class extendingSearchDelegate
. Now, the context will change when we move to the new Scaffold, so pass the instance ofSearchMovieBloc
in the constructor. If we don't do it this way, there will be 2 unnecessary instances created for one bloc.
Custom search delegate
Create a new folder in journeys with name search_movie. Here create a new file custom_search_movie_delegate.dart:
//1
class CustomSearchDelegate extends SearchDelegate {
//2
final SearchMovieBloc searchMovieBloc;
CustomSearchDelegate(this.searchMovieBloc);
//3
@override
List<Widget> buildActions(BuildContext context) {
throw UnimplementedError();
}
//4
@override
Widget buildLeading(BuildContext context) {
throw UnimplementedError();
}
//5
@override
Widget buildResults(BuildContext context) {
throw UnimplementedError();
}
//6
@override
Widget buildSuggestions(BuildContext context) {
throw UnimplementedError();
}
}
- Extend the class with
SearchDelegate
. - Pass the
SearchMovieBloc
as we will need to dispatch events and handle states when there is a search result obtained. - First overridden method would be
buildActions
that by the material library definition states the list of right-hand side trailing widgets of the input field. - Next would be
buildLeading
, again by the name itself states that this will be a single widget on the left-hand side of the input field. Generally, a back arrow comes here. - The
buildResults
will define what is the UI when the search is pressed after a query is entered by the user. - The
buildSuggestions
will be called when the user is typing and you have an API that returns with the suggested search text like auto-fill hints. Unfortunately, we don't have such API in TMDb, so will have an empty widget for this.
Right now, if you run the application, you'll get UnimplementedError
as no method is implemented yet. Let's return the empty widget and list of widgets wherever required.
After returning SizedBox.shrink()
in all the methods, we can see the screen with a search field. This is amazing the way SearchDelegate
has worked for us and minimized our work.
AppBar Theme
Let's first modify the search field now to match with our theme. Override one more method to modify the search field:
@override
ThemeData appBarTheme(BuildContext context) {
//1
return Theme.of(context).copyWith(
//2
inputDecorationTheme: InputDecorationTheme(
hintStyle: Theme.of(context).textTheme.greySubtitle1,
),
);
}
- We need to update
AppBarTheme
, so we will modify our currentAppBarTheme
that we have provided in app.dart. - To change the text color for the hint, we need to modify the
InputDecorationTheme
.
Leading Icon
Let's add the leading icon now:
@override
Widget buildLeading(BuildContext context) {
//2
return GestureDetector(
onTap: () {
close(context, null);
},
//1
child: Icon(
Icons.arrow_back_ios,
color: Colors.white,
size: Sizes.dimen_12.h,
),
);
}
- Add the back arrow with the same size as used in the home screen for leading and trailing icons.
- Now, to navigate back to the calling screen, use
close()
inGestureDetector
. Passnull
as a result because we are not reading the response.
Trailing Icon
Now, add a clear icon on the right-hand side that will clear the query text.
@override
List<Widget> buildActions(BuildContext context) {
return [
//1
IconButton(
icon: Icon(
Icons.clear,
//2
color: query.isEmpty ? Colors.grey : AppColor.royalBlue,
),
//3
onPressed: query.isEmpty ? null : () => query = '',
),
];
}
- Use the
clear
icon. - We can also change the color of the icon when the query is empty. You might be wondering what is this query coming from. Answer is
SearchDelegate
. Along with the methods, there is one field as well asquery
that stores the text typed by the user. - Based on the
query
length again, we can clear the query.
Build the Results
Before we build results let's see what the search result will look like. For ease, I have kept it very simple and used - just a left-side image and right-side title and description.
Create a new file search_movie_card.dart:
class SearchMovieCard extends StatelessWidget {
//1
final MovieEntity movie;
const SearchMovieCard({
Key key,
@required this.movie,
}) : super(key: key);
@override
Widget build(BuildContext context) {
//2
return GestureDetector(
onTap: () {
//3
Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => MovieDetailScreen(
movieDetailArguments: MovieDetailArguments(movie.id),
),
),
);
},
//4
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: Sizes.dimen_16.w,
vertical: Sizes.dimen_2.h,
),
//5
child: Row(
children: [
Padding(
padding: EdgeInsets.all(Sizes.dimen_8.w),
//6
child: ClipRRect(
borderRadius: BorderRadius.circular(Sizes.dimen_4.w),
child: CachedNetworkImage(
imageUrl: '${ApiConstants.BASE_IMAGE_URL}${movie.posterPath}',
width: Sizes.dimen_80.w,
),
),
),
//7
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
//8
Text(
movie.title,
style: Theme.of(context).textTheme.subtitle1,
),
//9
Text(
movie.overview,
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.greyCaption,
),
],
),
),
],
),
),
);
}
}
//10
TextStyle get greyCaption => caption.copyWith(
color: Colors.grey,
);
- This widget cannot work without a movie, so have a final field of movie and make it as required.
- When we will tap on the movie card, we will navigate to the detail screen so use
GestureDetector
. - Similar to how we navigate from carousel and tabs, here as well we will add the same code.
- To have some equal spacing from horizontal and vertical for the list tile, we will use
Padding
. - We are creating a
ListTile
like structure, so we needRow
first. - We need curved edges, so use
ClipRRect
onCachedNetworkImage
. - On the right-hand side of the image, we need
Column
for vertical alignment of title and overview. - First text will be the title of the movie.
- Second text will be a small overview of max 3 lines and if there is extra text, we use
ellipsis
. - We are creating a new text style so add that in the
ThemeText
as well.
Now, with this card, we will build the ListView
of these cards.
@override
Widget buildResults(BuildContext context) {
//1
searchMovieBloc.add(
SearchTermChangedEvent(query),
);
//2
return BlocBuilder<SearchMovieBloc, SearchMovieState>(
bloc: searchMovieBloc,
builder: (context, state) {
//3
if (state is SearchMovieError) {
//4
return AppErrorWidget(
errorType: AppErrorType.api,
onPressed: () =>
searchMovieBloc?.add(SearchTermChangedEvent(query)),
);
}
//3
else if (state is SearchMovieLoaded) {
final movies = state.movies;
//6
if (movies.isEmpty) {
return Center(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: Sizes.dimen_64.w),
child: Text(
TranslationConstants.noMoviesSearched.t(context),
textAlign: TextAlign.center,
),
),
);
}
//7
return ListView.builder(
itemBuilder: (context, index) => SearchMovieCard(
movie: movies[index],
),
itemCount: movies.length,
scrollDirection: Axis.vertical,
);
}
//3
else {
//5
return const SizedBox.shrink();
}
},
);
}
- As I said, the delegate calls
buildResults
when the user has pressed the search button. So, we will emit theSearchTermChangedEvent
as the first call in this method so that the API is hit and we wait for the success or the error state. - To update UI based on various states, use the
BlocBuilder
forSearchMovieBloc
. - Handle the error state, loaded state, and default state. We will handle the loading state later in the series altogether with other sections of the application.
- Like before, we will return the
AppErrorWidget
when there is an error in fetching the searched results. - For default state, we will not show anything hence return the
SizedBox.shrink()
. - In the success case, we can have 0 results as well, so for that, we will return a centered text. Declare this text in
TranslationConstants
, en.json and es.json. - When in a success case, there is a list of movies returned, we will return the
ListView
withSearchMovieCard
.
Now, if you run the application, you'll see the search results. Play with it.
One thing that I missed is handling the type of error. So, for that, we need to update the error state with AppErrorType
first.
class SearchMovieError extends SearchMovieState {
//1
final AppErrorType errorType;
SearchMovieError(this.errorType);
@override
List<Object> get props => [errorType];
}
- Like before, just add a field of
AppErrorType
that will contain which error type is thrown by the API call.
Now, update the SearchMovieBloc
as well:
yield response.fold(
//1
(l) => SearchMovieError(l.appErrorType),
(r) => SearchMovieLoaded(r),
);
- Pass in the error type in the state object.
Last thing would be to update the AppErrorWidget
in the buildResults()
:
return AppErrorWidget(
//1
errorType: state.errorType,
onPressed: () =>
searchMovieBloc?.add(SearchTermChangedEvent(query)),
);
- Here, pass in the error type from the state and rest the
AppErrorWidget
will handle the UI for you, like in other places.
Restart the app completely and check the network off scenarios as well.
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 learned something or others in this tutorial. See you in the next tutorial. Thanks for reading.