13. Search Movies

13. Search Movies

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';
}
  1. As optional parameters, add a map to the get(). This will not break existing get calls and will enhance the functionality.
  2. Instead of directly passing the resultant query string, create a method that will also consider using the parameters if passed.
  3. 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.
  4. 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.
  5. With this we are not only handling one param but N number of params. This is how you create generic methods.
  6. Append the key and value in the paramString.
  7. 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.

  1. Domain Layer - Request & Response Model, Method in abstract repository and usecase
  2. Data Layer - Model, Repository Implementation, and Datasource
  3. Presentation Layer - Add a new Bloc
  4. 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,
),
  1. Get the instance of SearchMovieBloc below the other blocs in HomeScreen.
  2. Dispose of the bloc like others.
  3. 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),
  ),
);
  1. This method needs context.
  2. It also needs a delegate that can create a new Scaffold screen for you on your behalf. The default implementation SearchDelegate consists of basic fields like search label, field, etc. But, to create custom searches, we need to create a class extending SearchDelegate. Now, the context will change when we move to the new Scaffold, so pass the instance of SearchMovieBloc 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();
  }
}
  1. Extend the class with SearchDelegate.
  2. Pass the SearchMovieBloc as we will need to dispatch events and handle states when there is a search result obtained.
  3. 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.
  4. 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.
  5. The buildResults will define what is the UI when the search is pressed after a query is entered by the user.
  6. 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,
    ),
  );
}
  1. We need to update AppBarTheme, so we will modify our current AppBarTheme that we have provided in app.dart.
  2. 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,
    ),
  );
}
  1. Add the back arrow with the same size as used in the home screen for leading and trailing icons.
  2. Now, to navigate back to the calling screen, use close() in GestureDetector. Pass null 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 = '',
    ),
  ];
}
  1. Use the clear icon.
  2. 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 as query that stores the text typed by the user.
  3. 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.

search_movie_card.png

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,
);
  1. This widget cannot work without a movie, so have a final field of movie and make it as required.
  2. When we will tap on the movie card, we will navigate to the detail screen so use GestureDetector.
  3. Similar to how we navigate from carousel and tabs, here as well we will add the same code.
  4. To have some equal spacing from horizontal and vertical for the list tile, we will use Padding.
  5. We are creating a ListTile like structure, so we need Row first.
  6. We need curved edges, so use ClipRRect on CachedNetworkImage.
  7. On the right-hand side of the image, we need Column for vertical alignment of title and overview.
  8. First text will be the title of the movie.
  9. Second text will be a small overview of max 3 lines and if there is extra text, we use ellipsis.
  10. 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();
      }
    },
  );
}
  1. As I said, the delegate calls buildResults when the user has pressed the search button. So, we will emit the SearchTermChangedEvent as the first call in this method so that the API is hit and we wait for the success or the error state.
  2. To update UI based on various states, use the BlocBuilder for SearchMovieBloc.
  3. 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.
  4. Like before, we will return the AppErrorWidget when there is an error in fetching the searched results.
  5. For default state, we will not show anything hence return the SizedBox.shrink().
  6. 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.
  7. When in a success case, there is a list of movies returned, we will return the ListView with SearchMovieCard.

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];
}
  1. 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),
);
  1. 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)),
);
  1. 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.

Did you find this article valuable?

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