import 'dart:async'; import 'github_search_api.dart'; import 'package:async/async.dart'; import 'package:redux/redux.dart'; import 'package:redux_epics/redux_epics.dart'; import 'package:rxdart/rxdart.dart'; import './SearchState.dart'; import './SearchResult.dart'; // The State represents the data the View requires. The View consumes a Stream // of States. The view rebuilds every time the Stream emits a new State! // // The State Stream will emit new States depending on the situation: The // initial state, loading states, the list of results, and any errors that // happen. // // The State Stream responds to input from the View by accepting a // Stream. We call this Stream the onTextChanged "intent". /// Actions class SearchAction { final String term; SearchAction(this.term); toJson() { return {'term': term}; } } class SearchLoadingAction {} class SearchErrorAction {} class SearchResultAction { final SearchResult result; toJson() { return {'result': result}; } SearchResultAction(this.result); } /// Reducer final searchReducer = combineReducers([ TypedReducer(_onLoad), TypedReducer(_onError), TypedReducer(_onResult), ]); SearchState _onLoad(SearchState state, SearchLoadingAction action) => SearchState.loading(); SearchState _onError(SearchState state, SearchErrorAction action) => SearchState.error(); SearchState _onResult(SearchState state, SearchResultAction action) => SearchState(result: action.result, isLoading: false); /// The Search Middleware will listen for Search Actions and perform the search /// after the user stop typing for 250ms. /// /// If a previous search was still loading, we will cancel the operation and /// fetch a new set of results. This ensures only results for the latest search /// term are shown. class SearchMiddleware implements MiddlewareClass { final GithubApi api; Timer _timer; CancelableOperation> _operation; SearchMiddleware(this.api); @override void call(Store store, dynamic action, NextDispatcher next) { if (action is SearchAction) { // Stop our previous debounce timer and search. _timer?.cancel(); _operation?.cancel(); // Don't start searching until the user pauses for 250ms. This will stop // us from over-fetching from our backend. _timer = new Timer(new Duration(milliseconds: 250), () { store.dispatch(SearchLoadingAction()); // Instead of a simple Future, we'll use a CancellableOperation from the // `async` package. This will allow us to cancel the previous operation // if a new Search term comes in. This will prevent us from // accidentally showing stale results. _operation = CancelableOperation.fromFuture(api .search(action.term) .then((result) => store..dispatch(SearchResultAction(result))) .catchError((e, s) => store..dispatch(SearchErrorAction()))); }); } // Make sure to forward actions to the next middleware in the chain! next(action); } } /// The Search Epic provides the same functionality as the Search Middleware, /// but uses redux_epics and the RxDart package to perform the work. It will /// listen for Search Actions and perform the search after the user stop typing /// for 250ms. /// /// If a previous search was still loading, we will cancel the operation and /// fetch a new set of results. This ensures only results for the latest search /// term are shown. class SearchEpic implements EpicClass { final GithubApi api; SearchEpic(this.api); @override Stream call(Stream actions, EpicStore store) { return Observable(actions) // Narrow down to SearchAction actions .ofType(TypeToken()) // Don't start searching until the user pauses for 250ms .debounce(new Duration(milliseconds: 250)) // Cancel the previous search and start a new one with switchMap .switchMap((action) => _search(action.term)); } // Use the async* function to make our lives easier Stream _search(String term) async* { // Dispatch a SearchLoadingAction to show a loading spinner yield SearchLoadingAction(); try { // If the api call is successful, dispatch the results for display yield SearchResultAction(await api.search(term)); } catch (e) { // If the search call fails, dispatch an error so we can show it yield SearchErrorAction(); } } }