GitHub Search
In questo tutorial costruiremo un’app GitHub Search in Flutter e AngularDart per dimostrare come condividere i livelli dati e la logica applicativa tra i due progetti.


Argomenti Chiave
Sezione intitolata “Argomenti Chiave”- BlocProvider, widget Flutter che fornisce un’istanza di bloc ai suoi figli;
- BlocBuilder, widget Flutter che gestisce la costruzione del widget in risposta a nuovi stati;
- Usare Cubit invece di Bloc. Qual è la differenza?;
- Prevenire aggiornamenti non necessari con Equatable;
- Usare un
EventTransformerpersonalizzato conbloc_concurrency; - Eseguire richieste di rete usando il pacchetto
http.
Libreria Condivisa GitHub Search
Sezione intitolata “Libreria Condivisa GitHub Search”La libreria “Common GitHub Search” conterrà i modelli, il data provider, il repository e il bloc che saranno condivisi tra AngularDart e Flutter.
Configurazione
Sezione intitolata “Configurazione”Inizieremo creando una nuova directory per l’applicazione.
mkdir -p github_search/common_github_searchDobbiamo creare un pubspec.yaml con le dipendenze richieste.
name: common_github_searchdescription: Shared Code between AngularDart and Flutterversion: 1.0.0+1publish_to: none
environment: sdk: ">=3.10.0 <4.0.0"
dependencies: bloc: ^9.0.0 equatable: ^2.0.0 http: ^1.0.0 stream_transform: ^2.0.0dev_dependencies: bloc_lint: ^0.3.0Infine, installiamo le dipendenze.
dart pub getQuesto è tutto per il setup del progetto! Ora possiamo iniziare a lavorare sulla
costruzione del pacchetto common_github_search.
Github Client
Sezione intitolata “Github Client”Il GithubClient fornirà i dati grezzi
dall’API GitHub.
Creiamo 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); } }
void close() { _httpClient.close(); }}Successivamente dobbiamo definire i nostri modelli SearchResult e
SearchResultError.
Modello Search Result
Sezione intitolata “Modello Search Result”Crea search_result.dart, che rappresenta una lista di SearchResultItems
basata sulla query dell’utente:
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;}Modello Search Result Item
Sezione intitolata “Modello Search Result Item”Successivamente, creeremo 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;}Modello GitHub User
Sezione intitolata “Modello GitHub User”Creiamo 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;}A questo punto abbiamo finito di implementare SearchResult e le sue
dipendenze. Ora passeremo a SearchResultError.
Modello Search Result Error
Sezione intitolata “Modello Search Result Error”Crea 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;}Il nostro GithubClient è finito. Passeremo ora al GithubCache, che sarà
responsabile della memoizzazione
per ottimizzare le prestazioni.
GitHub Cache
Sezione intitolata “GitHub Cache”Il nostro GithubCache sarà responsabile di ricordare tutte le query passate,
evitando così di fare richieste di rete non necessarie all’API GitHub. Questo
aiuterà anche a migliorare le prestazioni della nostra applicazione.
Crea 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);
void close() { _cache.clear(); }}Ora siamo pronti a creare il nostro GithubRepository!
GitHub Repository
Sezione intitolata “GitHub Repository”Il Github Repository ha il compito di creare un’astrazione tra il livello dati
(GithubClient) e il livello di logica applicativa (Bloc). È qui che
utilizzeremo il nostro GithubCache.
Crea github_repository.dart.
import 'dart:async';
import 'package:common_github_search/common_github_search.dart';
class GithubRepository { GithubRepository({GithubCache? cache, GithubClient? client}) : _cache = cache ?? GithubCache(), _client = client ?? GithubClient();
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; }
void dispose() { _cache.close(); _client.close(); }}A questo punto abbiamo completato il livello data provider e il livello repository; siamo pronti a passare al livello di logica applicativa.
GitHub Search Event
Sezione intitolata “GitHub Search Event”Il nostro Bloc sarà notificato quando un utente digita il nome di un repository;
rappresenteremo questa azione come un GithubSearchEvent di tipo TextChanged.
Crea 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 }';}Github Search State
Sezione intitolata “Github Search State”Il nostro livello di presentazione necessita di diverse informazioni per renderizzarsi correttamente:
SearchStateEmpty: indica al livello di presentazione che l’utente non ha fornito input;SearchStateLoading: indica al livello di presentazione di visualizzare un indicatore di caricamento;SearchStateSuccess: indica che ci sono dati da presentare.items: laList<SearchResultItem>che sarà visualizzata;
SearchStateError: indica che si è verificato un errore durante il recupero dei repository.error: l’errore esatto che si è verificato.
Possiamo ora creare github_search_state.dart e implementarlo così:
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];}Ora che abbiamo implementato i nostri Eventi e Stati, possiamo creare il nostro
GithubSearchBloc.
GitHub Search Bloc
Sezione intitolata “GitHub Search Bloc”Crea 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 GithubRepository githubRepository}) : _githubRepository = 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'), ); } }}Fantastico! Abbiamo finito con il nostro pacchetto common_github_search. Il
prodotto finito dovrebbe essere simile a
questo.
Successivamente, lavoreremo sull’implementazione Flutter.
Flutter GitHub Search
Sezione intitolata “Flutter GitHub Search”Flutter Github Search sarà un’applicazione Flutter che riutilizza modelli, data
provider, repository e bloc da common_github_search per implementare la
ricerca GitHub.
Configurazione
Sezione intitolata “Configurazione”Iniziamo creando un nuovo progetto Flutter nella nostra directory
github_search allo stesso livello di common_github_search.
flutter create flutter_github_searchDobbiamo aggiornare il nostro pubspec.yaml per includere tutte le dipendenze
necessarie.
name: flutter_github_searchdescription: A new Flutter project.version: 1.0.0+1publish_to: none
environment: sdk: ">=3.10.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: truedev_dependencies: bloc_lint: ^0.3.0Ora installiamo le dipendenze.
flutter pub getQuesto è tutto per il setup del progetto. Poiché il pacchetto
common_github_search contiene il nostro livello dati e la logica applicativa,
tutto ciò che dobbiamo costruire è il livello di presentazione.
Search Form
Sezione intitolata “Search Form”Dovremo creare un form con i widget _SearchBar e _SearchBody.
_SearchBarsarà responsabile della gestione dell’input utente;_SearchBodysarà responsabile della visualizzazione dei risultati di ricerca, indicatori di caricamento ed errori.
Creiamo search_form.dart. Il nostro SearchForm sarà un StatelessWidget che
renderizza i widget _SearchBar e _SearchBody.
_SearchBar sarà uno StatefulWidget perché dovrà mantenere il proprio
TextEditingController per tracciare l’input dell’utente.
_SearchBody è un StatelessWidget responsabile di mostrare risultati, errori
e caricamenti. Sarà il consumatore del GithubSearchBloc.
Se il nostro stato è SearchStateSuccess, renderizziamo _SearchResults (che
implementeremo successivamente). _SearchResults è un StatelessWidget che
prende una List<SearchResultItem> e la visualizza come lista di
_SearchResultItem.
_SearchResultItem è un StatelessWidget responsabile del rendering delle
informazioni per un singolo risultato. Gestisce anche l’interazione dell’utente,
navigando all’url del repository al tocco.
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)), ); }}Riassumendo
Sezione intitolata “Riassumendo”Ora tutto ciò che resta da fare è implementare la nostra app principale 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() => runApp(const App());
class App extends StatelessWidget { const App({super.key});
@override Widget build(BuildContext context) { return RepositoryProvider( create: (_) => GithubRepository(), dispose: (repository) => repository.dispose(), child: MaterialApp( title: 'GitHub Search', home: Scaffold( appBar: AppBar(title: const Text('GitHub Search')), body: BlocProvider( create: (context) => GithubSearchBloc( githubRepository: context.read<GithubRepository>(), ), child: const SearchForm(), ), ), ), ); }}Questo è tutto! Abbiamo implementato con successo un’app di ricerca GitHub in Flutter usando i pacchetti bloc e flutter_bloc, separando il livello di presentazione dalla logica applicativa.
Il codice sorgente completo può essere trovato qui.
Infine, costruiremo la nostra app AngularDart GitHub Search.
AngularDart GitHub Search
Sezione intitolata “AngularDart GitHub Search”AngularDart GitHub Search sarà un’applicazione AngularDart che riutilizza i
modelli, data provider, repository e bloc da common_github_search per
implementare Github Search.
Configurazione
Sezione intitolata “Configurazione”Dobbiamo iniziare creando un nuovo progetto AngularDart nella nostra directory
github_search, allo stesso livello di common_github_search.
stagehand web-angularPossiamo poi sostituire il contenuto di pubspec.yaml con:
name: angular_github_searchdescription: A web app that uses AngularDart Components
environment: sdk: ">=3.10.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.0Search Form
Sezione intitolata “Search Form”Proprio come nella nostra app Flutter, dovremo creare un SearchForm con un
componente SearchBar e SearchBody.
Il nostro componente SearchForm implementerà OnInit e OnDestroy perché
dovrà creare e chiudere un GithubSearchBloc.
SearchBarsarà responsabile della gestione dell’input utente;SearchBodysarà responsabile della visualizzazione dei risultati di ricerca, indicatori di caricamento ed errori.
Creiamo 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(); }}Il nostro template (search_form_component.html) sarà:
<div> <h1>GitHub Search</h1> <search-bar [githubSearchBloc]="githubSearchBloc"></search-bar> <search-body [state]="$pipe.bloc(githubSearchBloc)"></search-body></div>Successivamente, implementeremo il componente SearchBar.
Search Bar
Sezione intitolata “Search Bar”SearchBar è un componente responsabile di prendere l’input dell’utente e
notificare il GithubSearchBloc dei cambiamenti di testo.
Crea 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)); }}Successivamente, possiamo creare 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)"/>Abbiamo finito con SearchBar, ora passiamo a SearchBody.
Search Body
Sezione intitolata “Search Body”SearchBody è il componente responsabile della visualizzazione di risultati,
errori e indicatori di caricamento. Sarà il consumatore del GithubSearchBloc.
Crea 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 : '';}Crea 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>Se il nostro stato isSuccess, renderizziamo SearchResults. Lo implementeremo
ora.
Search Results
Sezione intitolata “Search Results”SearchResults è un componente che prende una List<SearchResultItem> e la
visualizza come una lista di SearchResultItem.
Crea 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;}Successivamente creeremo 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>È ora di implementare SearchResultItem.
Search Result Item
Sezione intitolata “Search Result Item”SearchResultItem è un componente responsabile del rendering delle informazioni
per un singolo risultato di ricerca. Gestisce anche l’interazione dell’utente e
la navigazione all’URL del repository.
Crea 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;}e il template corrispondente 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>Riassumendo
Sezione intitolata “Riassumendo”Abbiamo tutti i nostri componenti ed è il momento di assemblarli nel nostro
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();}Questo è tutto! Abbiamo implementato con successo un’app di ricerca GitHub in
AngularDart usando i pacchetti bloc e angular_bloc, separando il livello di
presentazione dalla logica applicativa.
Il codice sorgente completo può essere trovato qui.
Riepilogo
Sezione intitolata “Riepilogo”In questo tutorial abbiamo creato un’app Flutter e una AngularDart condividendo tutti i modelli, data provider e bloc tra le due. L’unica cosa che abbiamo dovuto scrivere due volte è stato il livello di presentazione (UI), il che è fantastico in termini di efficienza e velocità di sviluppo.
Inoltre, è piuttosto comune che app web e mobile abbiano esperienze utente e stili diversi; questo approccio dimostra quanto sia facile costruire due app che appaiono totalmente diverse ma condividono gli stessi livelli di dati e logica applicativa.
Il codice sorgente completo può essere trovato qui.