Salta ai contenuti

GitHub Search

advanced

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.

demo

demo

  • 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 EventTransformer personalizzato con bloc_concurrency;
  • Eseguire richieste di rete usando il pacchetto http.

La libreria “Common GitHub Search” conterrà i modelli, il data provider, il repository e il bloc che saranno condivisi tra AngularDart e Flutter.

Inizieremo creando una nuova directory per l’applicazione.

Terminal window
mkdir -p github_search/common_github_search

Dobbiamo creare un pubspec.yaml con le dipendenze richieste.

common_github_search/pubspec.yaml
name: common_github_search
description: Shared Code between AngularDart and Flutter
version: 1.0.0+1
publish_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.0
dev_dependencies:
bloc_lint: ^0.3.0

Infine, installiamo le dipendenze.

Terminal window
dart pub get

Questo è tutto per il setup del progetto! Ora possiamo iniziare a lavorare sulla costruzione del pacchetto common_github_search.

Il GithubClient fornirà i dati grezzi dall’API GitHub.

Creiamo github_client.dart.

common_github_search/lib/src/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.

Crea search_result.dart, che rappresenta una lista di SearchResultItems basata sulla query dell’utente:

lib/src/models/search_result.dart
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;
}

Successivamente, creeremo search_result_item.dart.

lib/src/models/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;
}

Creiamo github_user.dart.

lib/src/models/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.

Crea search_result_error.dart.

lib/src/models/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.

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.

lib/src/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!

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.

lib/src/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.

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.

lib/src/github_search_bloc/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 }';
}

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: la List<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ì:

lib/src/github_search_bloc/github_search_state.dart
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.

Crea github_search_bloc.dart:

lib/src/github_search_bloc/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 sarà un’applicazione Flutter che riutilizza modelli, data provider, repository e bloc da common_github_search per implementare la ricerca GitHub.

Iniziamo creando un nuovo progetto Flutter nella nostra directory github_search allo stesso livello di common_github_search.

Terminal window
flutter create flutter_github_search

Dobbiamo aggiornare il nostro pubspec.yaml per includere tutte le dipendenze necessarie.

flutter_github_search/pubspec.yaml
name: flutter_github_search
description: A new Flutter project.
version: 1.0.0+1
publish_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: true
dev_dependencies:
bloc_lint: ^0.3.0

Ora installiamo le dipendenze.

Terminal window
flutter pub get

Questo è 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.

Dovremo creare un form con i widget _SearchBar e _SearchBody.

  • _SearchBar sarà responsabile della gestione dell’input utente;
  • _SearchBody sarà 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.

flutter_github_search/lib/search_form.dart
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)),
);
}
}

Ora tutto ciò che resta da fare è implementare la nostra app principale in main.dart.

flutter_github_search/lib/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 sarà un’applicazione AngularDart che riutilizza i modelli, data provider, repository e bloc da common_github_search per implementare Github Search.

Dobbiamo iniziare creando un nuovo progetto AngularDart nella nostra directory github_search, allo stesso livello di common_github_search.

Terminal window
stagehand web-angular

Possiamo poi sostituire il contenuto di pubspec.yaml con:

angular_github_search/pubspec.yaml
name: angular_github_search
description: 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.0

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.

  • SearchBar sarà responsabile della gestione dell’input utente;
  • SearchBody sarà responsabile della visualizzazione dei risultati di ricerca, indicatori di caricamento ed errori.

Creiamo search_form_component.dart.

angular_github_search/lib/src/search_form/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à:

angular_github_search/lib/src/search_form/search_form_component.html
<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.

SearchBar è un componente responsabile di prendere l’input dell’utente e notificare il GithubSearchBloc dei cambiamenti di testo.

Crea search_bar_component.dart.

angular_github_search/lib/src/search_form/search_bar/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.

angular_github_search/lib/src/search_form/search_bar/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.

SearchBody è il componente responsabile della visualizzazione di risultati, errori e indicatori di caricamento. Sarà il consumatore del GithubSearchBloc.

Crea search_body_component.dart.

angular_github_search/lib/src/search_form/search_body/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.

angular_github_search/lib/src/search_form/search_body/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.

SearchResults è un componente che prende una List<SearchResultItem> e la visualizza come una lista di SearchResultItem.

Crea search_results_component.dart.

angular_github_search/lib/src/search_form/search_body/search_results/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.

angular_github_search/lib/src/search_form/search_body/search_results/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.

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.

angular_github_search/lib/src/search_form/search_body/search_results/search_result_item/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.

angular_github_search/lib/src/search_form/search_body/search_results/search_result_item/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>

Abbiamo tutti i nostri componenti ed è il momento di assemblarli nel nostro app_component.dart.

angular_github_search/lib/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.

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.