Github Search and BLoC

Github Search and BLoC

Implement Github search using the bloc

This article assumes that the reader has knowledge about the BLoC pattern

We will cover briefly about

  1. Integrate Github API
  2. Define UI states 
  3. Create Search BLoC
  4. Update UI as per states

Integrate Github API

We define an abstract class (aka contract), which includes one method.

Github Search and BLoC
Github Search and BLoC

Github has a public endpoint exposed for searching the repositories, and we append the user-defined search term to it.

https://api.github.com/search/repositories?q='YOUR SEARCH TERM'

So now, we implement the abstract class in our GithubApi (our implementation class name).

class GithubApi implements GithubSearchContract

and our search function looks like this

Github Search and BLoC

where we call the API, fetch the results, and convert them into the SearchResult model.

Define UI states

We formulate all the possible states our UI can have and then define them.

enum States {
noTerm,
error,
loading,
populated,
empty,
}

We create a base class (SearchState), and each state (defined above) will implement this base class.

@immutable
class SearchState extends BlocState {
  SearchState({this.state});
  final States state;
}

abstract class BlocState extends Equatable {
  @override
  List<Object> get props => [];
}

Our SearchState class is internally extending equatable. Equatable does the heavy lifting for equality comparisons between two objects.

Implement UI states

All the values inside the enum correspond to a UI state, currently, we have 5 values inside our enum, hence we will create 5 states.

class SearchNoTerm extends SearchState {
  SearchNoTerm() : super(state: States.noTerm);
}
class SearchError extends SearchState {
  SearchError() : super(state: States.error);
}
class SearchLoading extends SearchState {
  SearchLoading() : super(state: States.loading);
}
class SearchPopulated extends SearchState {
  final SearchResult result;
  SearchPopulated(this.result) : super(state: States.populated);
}
class SearchEmpty extends SearchState {
  SearchEmpty() : super(state: States.empty);
}

As we see here, each of the UI states also includes the respective value from the enum. For instance,

SearchNoTerm state has the value of States.noTerm , and so on

The results are only included in the SearchPopulated state, which has a SearchResult(our model class) parameter.

Create Search BLoC

The time has come, to create our much-anticipated BLoC.

Github Search and BLoC
Github Search and BLoC

The idea behind bloc is to expose sinks (for user-defined events) and react as per those events by emitting the respective states.

We define our search bloc which takes in the implementation of Github API as a parameter.

class SearchBloc {
  factory SearchBloc(GithubSearchContract api) {
   //.....
  }
  
  // Sink exposed to UI
  final Sink<String> onTextChanged;
  // State exposed to UI
  final Stream<SearchState> state;
}

We expose the onTextChanged sink and emit the stream of searchstate.

1. onTextChanged Sink

We use RxDart for defining what goes inside our sink.

RxDart adds additional capabilities to Dart Streams and StreamControllers.

factory SearchBloc(GithubSearchContract api) {
  final onTextChanged = PublishSubject<String>();
  final state = onTextChanged
        .distinct()
        .debounceTime(const Duration(milliseconds: 500))
        .switchMap<SearchState>((String term) =>                                                _helpers.eventTyping(term))
        .startWith(SearchNoTerm());
  return SearchBloc._(api, onTextChanged, state);
}

We create a PublishSubject of type string as we would be searching a string term.

PublishSubject: It emits all the subsequent items of the source Observable at the time of subscription.

Unlike a BehaviorSubject, a PublishSubject doesn’t retain/cache items, therefore, a new Observer won’t receive any past items

final subject = PublishSubject<int>();

// observer1 will receive all data and done events
subject.stream.listen(observer1);
subject.add(1);
subject.add(2);

// observer2 will only receive 3 and done event
subject.stream.listen(observe2);
subject.add(3);
subject.close();
Publish Subject
Publish Subject

2. Filtering sink

Now we need to filter the items entering our sink. We use distinct to skip the data events if they are equal to the previous data event.

The returned stream provides the same events as this stream, except that it never provides two consecutive data events that are equal.

Interactive description for distinct.


3. Debounce 

We wait for the user to stop typing for 500ms before running a search. This is achieved using debounce.

Stream.fromIterable([1, 2, 3, 4])  .debounceTime(Duration(seconds: 1))  .listen(print); // prints 4

4. switchMap

Call the Github API with the given search term. If another search term is entered, switchMap will ensure the previous search is discarded.

This can be useful when you only want the very latest state from asynchronous APIs, for example.

RangeStream(4, 1)  .switchMap((i) =>    TimerStream(i, Duration(minutes: i))  .listen(print); // prints 1

Finally, we call the Github API:

Stream<SearchState> eventTyping(String term) async* {
  if (term.isEmpty) {
    yield SearchEmpty();
  } else {
    yield* Rx.fromCallable(() => api.search(term))
        .map((result) =>
           result.isEmpty ? SearchEmpty() : SearchPopulated(result))
        .startWith(SearchLoading())
        .onErrorReturn(SearchError());
  }
}

  • where if the term is empty we emit SearchEmpty state
  • Otherwise, call the API, bundle the results into SearchPopulated state
  • In case of error, emit the SearchError state

Update UI as per states

Our bloc exposes a stream (called state). In our UI, we simply listen to this stream and react as per the state emitted

StreamBuilder<SearchState>(
   builder: (context, model) {
      final state = model.state;
      if (state == States.loading) {
          return const _Loading();
      } else if (state == States.empty || state == States.noTerm) {
          return const _Empty();
      } else if (state == States.error) {
          return const _Error();
      } else if (state == States.populated) {
          return const _DisplayWidget();
      }
      return const _Internal();
   },
   initialData: SearchNoTerm(),
   stream: searchBloc.state,
)
Hosted URL: https://web.flatteredwithflutter.com/#/
Source code for Flutter Web App.