GitHub Search
This content is not available in your language yet.
In the following tutorial, we’re going to build a GitHub Search app in Flutter and AngularDart to demonstrate how we can share the data and business logic layers between the two projects.
- BlocProvider, Flutter widget which provides a bloc to its children.
- BlocBuilder, Flutter widget that handles building the widget in response to new states.
- Using Cubit instead of Bloc. What’s the difference?
- Prevent unnecessary rebuilds with Equatable.
- Use a custom
EventTransformer
withbloc_concurrency
. - Making network requests using the
http
package.
The Common GitHub Search library will contain models, the data provider, the repository, as well as the bloc that will be shared between AngularDart and Flutter.
We’ll start off by creating a new directory for our application.
mkdir -p github_search/common_github_search
We need to create a pubspec.yaml
with the required dependencies.
name: common_github_searchdescription: Shared Code between AngularDart and Flutterversion: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: bloc: ^9.0.0 equatable: ^2.0.0 http: ^1.0.0 stream_transform: ^2.0.0
Lastly, we need to install our dependencies.
dart pub get
That’s it for the project setup! Now we can get to work on building out the common_github_search
package.
The GithubClient
which will be providing raw data from the GitHub API.
Let’s create github_client.dart
.
import 'dart:async';import 'dart:convert';
import 'package:common_github_search/common_github_search.dart';import 'package:http/http.dart' as http;
class GithubClient { GithubClient({ http.Client? httpClient, this.baseUrl = 'https://api.github.com/search/repositories?q=', }) : httpClient = httpClient ?? http.Client();
final String baseUrl; final http.Client httpClient;
Future<SearchResult> search(String term) async { final response = await httpClient.get(Uri.parse('$baseUrl$term')); final results = json.decode(response.body) as Map<String, dynamic>;
if (response.statusCode == 200) { return SearchResult.fromJson(results); } else { throw SearchResultError.fromJson(results); } }}
Next we need to define our SearchResult
and SearchResultError
models.
Create search_result.dart
, which represents a list of SearchResultItems
based on the user’s query:
import 'package:common_github_search/common_github_search.dart';
class SearchResult { const SearchResult({required this.items});
factory SearchResult.fromJson(Map<String, dynamic> json) { final items = (json['items'] as List<dynamic>) .map( (dynamic item) => SearchResultItem.fromJson(item as Map<String, dynamic>), ) .toList(); return SearchResult(items: items); }
final List<SearchResultItem> items;}
Next, we’ll create search_result_item.dart
.
import 'package:common_github_search/common_github_search.dart';
class SearchResultItem { const SearchResultItem({ required this.fullName, required this.htmlUrl, required this.owner, });
factory SearchResultItem.fromJson(Map<String, dynamic> json) { return SearchResultItem( fullName: json['full_name'] as String, htmlUrl: json['html_url'] as String, owner: GithubUser.fromJson(json['owner'] as Map<String, dynamic>), ); }
final String fullName; final String htmlUrl; final GithubUser owner;}
Next, we’ll create github_user.dart
.
class GithubUser { const GithubUser({ required this.login, required this.avatarUrl, });
factory GithubUser.fromJson(Map<String, dynamic> json) { return GithubUser( login: json['login'] as String, avatarUrl: json['avatar_url'] as String, ); }
final String login; final String avatarUrl;}
At this point, we have finished implementing SearchResult
and its dependencies. Now we’ll move onto SearchResultError
.
Create search_result_error.dart
.
class SearchResultError implements Exception { SearchResultError({required this.message});
factory SearchResultError.fromJson(Map<String, dynamic> json) { return SearchResultError( message: json['message'] as String, ); }
final String message;}
Our GithubClient
is finished so next we’ll move onto the GithubCache
, which will be responsible for memoizing as a performance optimization.
Our GithubCache
will be responsible for remembering all past queries so that we can avoid making unnecessary network requests to the GitHub API. This will also help improve our application’s performance.
Create github_cache.dart
.
import 'package:common_github_search/common_github_search.dart';
class GithubCache { final _cache = <String, SearchResult>{};
SearchResult? get(String term) => _cache[term];
void set(String term, SearchResult result) => _cache[term] = result;
bool contains(String term) => _cache.containsKey(term);
void remove(String term) => _cache.remove(term);}
Now we’re ready to create our GithubRepository
!
The Github Repository is responsible for creating an abstraction between the data layer (GithubClient
) and the Business Logic Layer (Bloc
). This is also where we’re going to put our GithubCache
to use.
Create github_repository.dart
.
import 'dart:async';
import 'package:common_github_search/common_github_search.dart';
class GithubRepository { const GithubRepository(this.cache, this.client);
final GithubCache cache; final GithubClient client;
Future<SearchResult> search(String term) async { final cachedResult = cache.get(term); if (cachedResult != null) { return cachedResult; } final result = await client.search(term); cache.set(term, result); return result; }}
At this point, we’ve completed the data provider layer and the repository layer so we’re ready to move on to the business logic layer.
Our Bloc will be notified when a user has typed the name of a repository which we will represent as a TextChanged
GithubSearchEvent
.
Create github_search_event.dart
.
import 'package:equatable/equatable.dart';
sealed class GithubSearchEvent extends Equatable { const GithubSearchEvent();}
final class TextChanged extends GithubSearchEvent { const TextChanged({required this.text});
final String text;
@override List<Object> get props => [text];
@override String toString() => 'TextChanged { text: $text }';}
Our presentation layer will need to have several pieces of information in order to properly lay itself out:
-
SearchStateEmpty
- will tell the presentation layer that no input has been given by the user. -
SearchStateLoading
- will tell the presentation layer it has to display some sort of loading indicator. -
SearchStateSuccess
- will tell the presentation layer that it has data to present.items
- will be theList<SearchResultItem>
which will be displayed.
-
SearchStateError
- will tell the presentation layer that an error has occurred while fetching repositories.error
- will be the exact error that occurred.
We can now create github_search_state.dart
and implement it like so.
import 'package:common_github_search/common_github_search.dart';import 'package:equatable/equatable.dart';
sealed class GithubSearchState extends Equatable { const GithubSearchState();
@override List<Object> get props => [];}
final class SearchStateEmpty extends GithubSearchState {}
final class SearchStateLoading extends GithubSearchState {}
final class SearchStateSuccess extends GithubSearchState { const SearchStateSuccess(this.items);
final List<SearchResultItem> items;
@override List<Object> get props => [items];
@override String toString() => 'SearchStateSuccess { items: ${items.length} }';}
final class SearchStateError extends GithubSearchState { const SearchStateError(this.error);
final String error;
@override List<Object> get props => [error];}
Now that we have our Events and States implemented, we can create our GithubSearchBloc
.
Create github_search_bloc.dart
:
import 'package:bloc/bloc.dart';import 'package:common_github_search/common_github_search.dart';import 'package:stream_transform/stream_transform.dart';
const _duration = Duration(milliseconds: 300);
EventTransformer<Event> debounce<Event>(Duration duration) { return (events, mapper) => events.debounce(duration).switchMap(mapper);}
class GithubSearchBloc extends Bloc<GithubSearchEvent, GithubSearchState> { GithubSearchBloc({required this.githubRepository}) : super(SearchStateEmpty()) { on<TextChanged>(_onTextChanged, transformer: debounce(_duration)); }
final GithubRepository githubRepository;
Future<void> _onTextChanged( TextChanged event, Emitter<GithubSearchState> emit, ) async { final searchTerm = event.text;
if (searchTerm.isEmpty) return emit(SearchStateEmpty());
emit(SearchStateLoading());
try { final results = await githubRepository.search(searchTerm); emit(SearchStateSuccess(results.items)); } catch (error) { emit( error is SearchResultError ? SearchStateError(error.message) : const SearchStateError('something went wrong'), ); } }}
Awesome! We’re all done with our common_github_search
package.
The finished product should look like this.
Next, we’ll work on the Flutter implementation.
Flutter Github Search will be a Flutter application which reuses the models, data providers, repositories, and blocs from common_github_search
to implement Github Search.
We need to start by creating a new Flutter project in our github_search
directory at the same level as common_github_search
.
flutter create flutter_github_search
Next, we need to update our pubspec.yaml
to include all the necessary dependencies.
name: flutter_github_searchdescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: bloc: ^9.0.0 common_github_search: path: ../common_github_search flutter: sdk: flutter flutter_bloc: ^9.0.1 url_launcher: ^6.0.0
flutter: uses-material-design: true
Now, we need to install the dependencies.
flutter packages get
That’s it for project setup. Since the common_github_search
package contains our data layer as well as our business logic layer, all we need to build is the presentation layer.
We’re going to need to create a form with a _SearchBar
and _SearchBody
widget.
_SearchBar
will be responsible for taking user input._SearchBody
will be responsible for displaying search results, loading indicators, and errors.
Let’s create search_form.dart
.
Our SearchForm
will be a StatelessWidget
which renders the _SearchBar
and _SearchBody
widgets.
_SearchBar
is also going to be a StatefulWidget
because it will need to maintain its own TextEditingController
so that we can keep track of what a user has entered as input.
_SearchBody
is a StatelessWidget
which will be responsible for displaying search results, errors, and loading indicators. It will be the consumer of the GithubSearchBloc
.
If our state is SearchStateSuccess
, we render _SearchResults
which we will implement next.
_SearchResults
is a StatelessWidget
which takes a List<SearchResultItem>
and displays them as a list of _SearchResultItems
.
_SearchResultItem
is a StatelessWidget
and is responsible for rendering the information for a single search result. It is also responsible for handling user interaction and navigating to the repository url on a user tap.
import 'package:common_github_search/common_github_search.dart';import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:url_launcher/url_launcher.dart';
class SearchForm extends StatelessWidget { const SearchForm({super.key});
@override Widget build(BuildContext context) { return Column( children: <Widget>[ _SearchBar(), _SearchBody(), ], ); }}
class _SearchBar extends StatefulWidget { @override State<_SearchBar> createState() => _SearchBarState();}
class _SearchBarState extends State<_SearchBar> { final _textController = TextEditingController(); late GithubSearchBloc _githubSearchBloc;
@override void initState() { super.initState(); _githubSearchBloc = context.read<GithubSearchBloc>(); }
@override void dispose() { _textController.dispose(); super.dispose(); }
@override Widget build(BuildContext context) { return TextField( controller: _textController, autocorrect: false, onChanged: (text) { _githubSearchBloc.add( TextChanged(text: text), ); }, decoration: InputDecoration( prefixIcon: const Icon(Icons.search), suffixIcon: GestureDetector( onTap: _onClearTapped, child: const Icon(Icons.clear), ), border: InputBorder.none, hintText: 'Enter a search term', ), ); }
void _onClearTapped() { _textController.text = ''; _githubSearchBloc.add(const TextChanged(text: '')); }}
class _SearchBody extends StatelessWidget { @override Widget build(BuildContext context) { return BlocBuilder<GithubSearchBloc, GithubSearchState>( builder: (context, state) { return switch (state) { SearchStateEmpty() => const Text('Please enter a term to begin'), SearchStateLoading() => const CircularProgressIndicator.adaptive(), SearchStateError() => Text(state.error), SearchStateSuccess() => state.items.isEmpty ? const Text('No Results') : Expanded(child: _SearchResults(items: state.items)), }; }, ); }}
class _SearchResults extends StatelessWidget { const _SearchResults({required this.items});
final List<SearchResultItem> items;
@override Widget build(BuildContext context) { return ListView.builder( itemCount: items.length, itemBuilder: (BuildContext context, int index) { return _SearchResultItem(item: items[index]); }, ); }}
class _SearchResultItem extends StatelessWidget { const _SearchResultItem({required this.item});
final SearchResultItem item;
@override Widget build(BuildContext context) { return ListTile( leading: CircleAvatar( child: Image.network(item.owner.avatarUrl), ), title: Text(item.fullName), onTap: () => launchUrl(Uri.parse(item.htmlUrl)), ); }}
Now all that’s left to do is implement our main app in main.dart
.
import 'package:common_github_search/common_github_search.dart';import 'package:flutter/material.dart';import 'package:flutter_bloc/flutter_bloc.dart';import 'package:flutter_github_search/search_form.dart';
void main() { final githubRepository = GithubRepository( GithubCache(), GithubClient(), );
runApp(App(githubRepository: githubRepository));}
class App extends StatelessWidget { const App({required this.githubRepository, super.key});
final GithubRepository githubRepository;
@override Widget build(BuildContext context) { return MaterialApp( title: 'GitHub Search', home: Scaffold( appBar: AppBar(title: const Text('GitHub Search')), body: BlocProvider( create: (_) => GithubSearchBloc(githubRepository: githubRepository), child: const SearchForm(), ), ), ); }}
That’s all there is to it! We’ve now successfully implemented a GitHub search app in Flutter using the bloc and flutter_bloc packages and we’ve successfully separated our presentation layer from our business logic.
The full source can be found here.
Finally, we’re going to build our AngularDart GitHub Search app.
AngularDart GitHub Search will be an AngularDart application which reuses the models, data providers, repositories, and blocs from common_github_search
to implement Github Search.
We need to start by creating a new AngularDart project in our github_search directory at the same level as common_github_search
.
stagehand web-angular
We can then go ahead and replace the contents of pubspec.yaml
with:
name: angular_github_searchdescription: A web app that uses AngularDart Components
environment: sdk: ">=3.6.0 <4.0.0"
dependencies: angular_bloc: ^10.0.0-dev.5 bloc: ^9.0.0 common_github_search: path: ../common_github_search ngdart: ^8.0.0-dev.4
dev_dependencies: build_daemon: ^4.0.0 build_runner: ^2.0.0 build_web_compilers: ^4.0.0
Just like in our Flutter app, we’re going to need to create a SearchForm
with a SearchBar
and SearchBody
component.
Our SearchForm
component will implement OnInit
and OnDestroy
because it will need to create and close a GithubSearchBloc
.
SearchBar
will be responsible for taking user input.SearchBody
will be responsible for displaying search results, loading indicators, and errors.
Let’s create search_form_component.dart.
import 'package:angular_bloc/angular_bloc.dart';import 'package:angular_github_search/src/github_search.dart';import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-form', templateUrl: 'search_form_component.html', directives: [ SearchBarComponent, SearchBodyComponent, ], pipes: [BlocPipe],)class SearchFormComponent implements OnInit, OnDestroy { @Input() late GithubRepository githubRepository;
late GithubSearchBloc githubSearchBloc;
@override void ngOnInit() { githubSearchBloc = GithubSearchBloc( githubRepository: githubRepository, ); }
@override void ngOnDestroy() { githubSearchBloc.close(); }}
Our template (search_form_component.html
) will look like:
<div> <h1>GitHub Search</h1> <search-bar [githubSearchBloc]="githubSearchBloc"></search-bar> <search-body [state]="$pipe.bloc(githubSearchBloc)"></search-body></div>
Next, we’ll implement the SearchBar
component.
SearchBar
is a component which will be responsible for taking in user input and notifying the GithubSearchBloc
of text changes.
Create search_bar_component.dart
.
import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-bar', templateUrl: 'search_bar_component.html',)class SearchBarComponent { @Input() late GithubSearchBloc githubSearchBloc;
void onTextChanged(String text) { githubSearchBloc.add(TextChanged(text: text)); }}
Next, we can create search_bar_component.html
.
<label for="term" class="clip">Enter a search term</label><input id="term" placeholder="Enter a search term" class="input-reset outline-transparent glow o-50 bg-near-black near-white w-100 pv2 border-box b--white-50 br-0 bl-0 bt-0 bb-ridge mb3" autofocus (keyup)="onTextChanged($event.target.value)"/>
We’re done with SearchBar
, now onto SearchBody
.
SearchBody
is a component which will be responsible for displaying search results, errors, and loading indicators. It will be the consumer of the GithubSearchBloc
.
Create search_body_component.dart
.
import 'package:angular_github_search/src/github_search.dart';import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-body', templateUrl: 'search_body_component.html', directives: [ coreDirectives, SearchResultsComponent, ],)class SearchBodyComponent { @Input() late GithubSearchState state;
bool get isEmpty => state is SearchStateEmpty; bool get isLoading => state is SearchStateLoading; bool get isSuccess => state is SearchStateSuccess; bool get isError => state is SearchStateError;
List<SearchResultItem> get items => isSuccess ? (state as SearchStateSuccess).items : [];
String get error => isError ? (state as SearchStateError).error : '';}
Create search_body_component.html
.
<div *ngIf="state != null" class="mw10"> <div *ngIf="isEmpty" class="tc"> <span>🔍</span> <p>Please enter a term to begin</p> </div> <div *ngIf="isLoading"> <div class="sk-chase center"> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> <div class="sk-chase-dot"></div> </div> </div> <div *ngIf="isError" class="tc"> <span>‼️</span> <p>{{ error }}</p> </div> <div *ngIf="isSuccess"> <div *ngIf="items.length == 0" class="tc"> <span>⚠️</span> <p>No Results</p> </div> <search-results [items]="items"></search-results> </div></div>
If our state isSuccess
, we render SearchResults
. We will implement it next.
SearchResults
is a component which takes a List<SearchResultItem>
and displays them as a list of SearchResultItems
.
Create search_results_component.dart
.
import 'package:angular_github_search/src/github_search.dart';import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-results', templateUrl: 'search_results_component.html', directives: [coreDirectives, SearchResultItemComponent],)class SearchResultsComponent { @Input() late List<SearchResultItem> items;}
Next up we’ll create search_results_component.html
.
<ul class="list pa0 ma0"> <li *ngFor="let item of items" class="pa2 cf"> <search-result-item [item]="item"></search-result-item> </li></ul>
It’s time to implement SearchResultItem
.
SearchResultItem
is a component that is responsible for rendering the information for a single search result. It is also responsible for handling user interaction and navigating to the repository url on a user tap.
Create search_result_item_component.dart
.
import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'search-result-item', templateUrl: 'search_result_item_component.html',)class SearchResultItemComponent { @Input() late SearchResultItem item;}
and the corresponding template in search_result_item_component.html
.
<div class="fl w-10 h-auto"> <img class="br-100" src="{{ item.owner.avatarUrl }}" /></div><div class="fl w-90 ph3"> <h1 class="f5 ma0">{{ item.fullName }}</h1> <p> <a href="{{ item.htmlUrl }}" class="light-blue" target="_blank">{{ item.htmlUrl }}</a> </p></div>
We have all of our components and now it’s time to put them all together in our app_component.dart
.
import 'package:angular_github_search/src/github_search.dart';import 'package:common_github_search/common_github_search.dart';import 'package:ngdart/angular.dart';
@Component( selector: 'my-app', template: '<search-form [githubRepository]="githubRepository"></search-form>', directives: [SearchFormComponent],)class AppComponent { final githubRepository = GithubRepository( GithubCache(), GithubClient(), );}
That’s all there is to it! We’ve now successfully implemented a GitHub search app in AngularDart using the bloc
and angular_bloc
packages and we’ve successfully separated our presentation layer from our business logic.
The full source can be found here.
In this tutorial we created a Flutter and AngularDart app while sharing all of the models, data providers, and blocs between the two.
The only thing we actually had to write twice was the presentation layer (UI) which is awesome in terms of efficiency and development speed. In addition, it’s fairly common for web apps and mobile apps to have different user experiences and styles and this approach really demonstrates how easy it is to build two apps that look totally different but share the same data and business logic layers.
The full source can be found here.