Search Page

We'll here's a short and easy one for today. The search page will use the same search repository and service as the user on boarding one. So let's take a look at what we're building today.

Search Page

Where do we start? The same place we always start pinky. The cubit.

@freezed
abstract class SearchState with _$SearchState {
  factory SearchState.loading() = _Loading;
  factory SearchState.loaded(SearchPodcastResults results) = _Loaded;
}
@injectable
class SearchCubit extends Cubit<SearchState> {
  final SearchService _searchService;
  SearchCubit(this._searchService)
      : super(SearchState.loaded(SearchPodcastResults([])));

  Future<void> searchPodcast(String searchString) async {
    emit(SearchState.loading());
    var results = await _searchService.searchPodcast(searchString);
    emit(SearchState.loaded(results));
  }
}

We start with a cubit of an "empty" initial state. We create a result with an empty list. When requested we emit loading, get the results, and emit that. Now let's look at the page.

class SearchPage extends StatelessWidget {
  final _searchCubit = GetIt.instance.get<SearchCubit>();

  Widget _buildLoading() {
    return Container(
      child: Center(child: LoadingWidget()),
    );
  }

  Widget _buildResults(SearchPodcastResults results) {
    if (results.results.isEmpty) {
      return Container(
        child: Center(
          child: Text("No podcasts found!"),
        ),
      );
    }
    return ListView.separated(
      shrinkWrap: true,
      separatorBuilder: (_, index) => Divider(),
      itemCount: results.results.length,
      itemBuilder: (BuildContext context, int index) {
        var podcast = results.results[index];
        return Row(
          children: [
            Container(
              constraints: BoxConstraints(maxHeight: 100, maxWidth: 100),
              child: Image.network(podcast.thumbnail),
            ),
            SizedBox(width: 4),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(
                    podcast.titleOriginal,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: Theme.of(context).textTheme.headline3,
                  ),
                  Text(
                    podcast.descriptionOriginal,
                    maxLines: 3,
                    overflow: TextOverflow.ellipsis,
                    style: Theme.of(context).textTheme.bodyText2,
                  ),
                ],
              ),
            )
          ],
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: EdgeInsets.all(8),
      child: Column(
        children: [
          TextField(
            style: TextStyle(
              height: 2,
            ),
            decoration: InputDecoration(
              suffixIcon: Icon(Icons.search),
              filled: true,
              fillColor: Colors.grey,
              hintText: 'Search',
              contentPadding:
                  const EdgeInsets.only(left: 14.0, bottom: 8.0, top: 8.0),
              focusedBorder: OutlineInputBorder(
                borderSide: BorderSide(color: Colors.grey),
                borderRadius: BorderRadius.circular(25.7),
              ),
              enabledBorder: UnderlineInputBorder(
                borderSide: BorderSide(color: Colors.grey),
                borderRadius: BorderRadius.circular(25.7),
              ),
            ),
            onChanged: (searchString) =>
                _searchCubit.searchPodcast(searchString),
          ),
          SizedBox(height: 8),
          Expanded(
            child: BlocBuilder(
              cubit: _searchCubit,
              builder: (BuildContext context, SearchState state) {
                return state.when(
                  loading: _buildLoading,
                  loaded: _buildResults,
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

This looks a little longer than our last one, but a lot of it is decoration. The core is actually fairly simple.

Let's start with the search box.

TextField(
            style: TextStyle(
              height: 2,
            ),
            decoration: InputDecoration(
              suffixIcon: Icon(Icons.search),
              filled: true,
              fillColor: Colors.grey,
              hintText: 'Search',
              contentPadding:
                  const EdgeInsets.only(left: 14.0, bottom: 8.0, top: 8.0),
              focusedBorder: OutlineInputBorder(
                borderSide: BorderSide(color: Colors.grey),
                borderRadius: BorderRadius.circular(25.7),
              ),
              enabledBorder: UnderlineInputBorder(
                borderSide: BorderSide(color: Colors.grey),
                borderRadius: BorderRadius.circular(25.7),
              ),
            ),
            onChanged: (searchString) =>
                _searchCubit.searchPodcast(searchString),
          ),

Most of this is styling. The only logic here is when text is entered, we go searching trough the cubit. That's all!

Next up is the bloc builder

Expanded(
            child: BlocBuilder(
              cubit: _searchCubit,
              builder: (BuildContext context, SearchState state) {
                return state.when(
                  loading: _buildLoading,
                  loaded: _buildResults,
                );
              },
            ),
          ),

From here we see the two states. The loading one builds the standard loading icon, so let's look at the build results.

  Widget _buildResults(SearchPodcastResults results) {
    if (results.results.isEmpty) {
      return Container(
        child: Center(
          child: Text("No podcasts found!"),
        ),
      );
    }
    return ListView.separated(
      shrinkWrap: true,
      separatorBuilder: (_, index) => Divider(),
      itemCount: results.results.length,
      itemBuilder: (BuildContext context, int index) {
        var podcast = results.results[index];
        return Row(
          children: [
            Container(
              constraints: BoxConstraints(maxHeight: 100, maxWidth: 100),
              child: Image.network(podcast.thumbnail),
            ),
            SizedBox(width: 4),
            Expanded(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(
                    podcast.titleOriginal,
                    maxLines: 2,
                    overflow: TextOverflow.ellipsis,
                    style: Theme.of(context).textTheme.headline3,
                  ),
                  Text(
                    podcast.descriptionOriginal,
                    maxLines: 3,
                    overflow: TextOverflow.ellipsis,
                    style: Theme.of(context).textTheme.bodyText2,
                  ),
                ],
              ),
            )
          ],
        );
      },
    );
  }

If we don't have any results, we display a simple message. Otherwise we build a list view that has a divider in between each item. Each item displays the thumbnail, title, and description.

That's the entire page!

Adding the page to the bottom bar

From our home.dart we've only had to change two items.

List<BottomNavigationBarItem> _getNavigationItems = [
    BottomNavigationBarItem(icon: Icon(Icons.home), label: "Home"),
    BottomNavigationBarItem(icon: Icon(Icons.search), label: "Search"),
  ];

  List<Widget> _pages = [
    PodcastHomePage(),
    SearchPage(),
  ];

In the previous post we set up the home page to have the bottom navigation. All we need to do now is replace our place holder "home 2" with our search page. Everything just works!

That's all folks!

Like I said at the start, this was going to be a short post. You can view the source code at https://github.com/cadaniel/FCast and see the progress as well as all unit tests.

Like what you see?

If you want to follow along and get updates, make sure you subscribe! If you want to support this app, consider a paid subscription. You'll get early access to posts, you'll also become a tester for this app, getting early access!

Casey Daniel

Casey Daniel

Canada
Tracy Pyett

Tracy Pyett