Пошук GitHub
У наступному посібнику ми створимо додаток пошуку GitHub у Flutter та AngularDart, щоб продемонструвати, як ми можемо спільно використовувати шари даних та бізнес-логіки між двома проєктами.


Ключові теми
Section titled “Ключові теми”- BlocProvider, Flutter віджет, який надає bloc своїм нащадкам.
- BlocBuilder, Flutter віджет, який обробляє побудову віджета у відповідь на нові стани.
- Використання Cubit замість Bloc. У чому різниця?
- Запобігання непотрібним перебудовам за допомогою Equatable.
- Використання користувацького
EventTransformerзbloc_concurrency. - Виконання мережевих запитів за допомогою пакету
http.
Спільна бібліотека пошуку GitHub
Section titled “Спільна бібліотека пошуку GitHub”Спільна бібліотека пошуку GitHub міститиме моделі, провайдер даних, сховище, а також bloc, який буде спільно використовуватися між AngularDart та Flutter.
Налаштування
Section titled “Налаштування”Ми почнемо зі створення нової директорії для нашого додатку.
mkdir -p github_search/common_github_searchНам потрібно створити pubspec.yaml з необхідними залежностями.
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.0Нарешті, нам потрібно встановити наші залежності.
dart pub getЦе все для налаштування проєкту! Тепер ми можемо приступити до роботи над
створенням пакету common_github_search.
Github Client
Section titled “Github Client”GithubClient надаватиме необроблені дані з
GitHub API.
Давайте створимо 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(); }}Далі нам потрібно визначити наші моделі SearchResult та SearchResultError.
Модель результату пошуку
Section titled “Модель результату пошуку”Створіть search_result.dart, який представляє список SearchResultItems на
основі запиту користувача:
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;}Модель елемента результату пошуку
Section titled “Модель елемента результату пошуку”Далі ми створимо 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;}Модель користувача GitHub
Section titled “Модель користувача GitHub”Далі ми створимо 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;}На цьому етапі ми завершили реалізацію SearchResult та його залежностей. Тепер
перейдемо до SearchResultError.
Модель помилки результату пошуку
Section titled “Модель помилки результату пошуку”Створіть 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;}Наш GithubClient завершений, тому далі ми перейдемо до GithubCache, який
відповідатиме за мемоізацію як
оптимізацію продуктивності.
GitHub Cache
Section titled “GitHub Cache”Наш GithubCache відповідатиме за запам’ятовування всіх минулих запитів, щоб ми
могли уникнути непотрібних мережевих запитів до GitHub API. Це також допоможе
покращити продуктивність нашого додатку.
Створіть 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(); }}Тепер ми готові створити наш GithubRepository!
GitHub Repository
Section titled “GitHub Repository”Github Repository відповідає за створення абстракції між шаром даних
(GithubClient) та шаром бізнес-логіки (Bloc). Тут також ми будемо
використовувати наш GithubCache.
Створіть 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(); }}На цьому етапі ми завершили шар провайдера даних та шар сховища, тому ми готові перейти до шару бізнес-логіки.
GitHub Search Event
Section titled “GitHub Search Event”Наш Bloc буде сповіщений, коли користувач введе назву репозиторію, що ми
представимо як TextChanged GithubSearchEvent.
Створіть 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
Section titled “Github Search State”Наш шар представлення повинен мати кілька частин інформації, щоб правильно себе відобразити:
-
SearchStateEmpty— повідомить шару представлення, що користувач не надав жодного введення. -
SearchStateLoading— повідомить шару представлення, що він повинен відобразити якийсь індикатор завантаження. -
SearchStateSuccess— повідомить шару представлення, що він має дані для відображення.items— будеList<SearchResultItem>, який буде відображено.
-
SearchStateError— повідомить шару представлення, що виникла помилка при отриманні репозиторіїв.error— буде точна помилка, яка виникла.
Тепер ми можемо створити 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];}Тепер, коли ми реалізували наші події та стани, ми можемо створити наш
GithubSearchBloc.
GitHub Search Bloc
Section titled “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'), ); } }}Чудово! Ми завершили наш пакет common_github_search. Готовий продукт повинен
виглядати як
цей.
Далі ми працюватимемо над реалізацією на Flutter.
Flutter GitHub Search
Section titled “Flutter GitHub Search”Flutter Github Search буде додатком Flutter, який повторно використовує моделі,
провайдери даних, сховища та блоки з common_github_search для реалізації
пошуку Github.
Налаштування
Section titled “Налаштування”Нам потрібно почати зі створення нового проєкту Flutter у нашій директорії
github_search на тому ж рівні, що й common_github_search.
flutter create flutter_github_searchДалі нам потрібно оновити наш pubspec.yaml, щоб включити всі необхідні
залежності.
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.0Тепер нам потрібно встановити залежності.
flutter pub getЦе все для налаштування проєкту. Оскільки пакет common_github_search містить
наш шар даних, а також наш шар бізнес-логіки, все, що нам потрібно побудувати —
це шар представлення.
Форма пошуку
Section titled “Форма пошуку”Нам потрібно створити форму з віджетами _SearchBar та _SearchBody.
_SearchBarвідповідатиме за отримання введення від користувача._SearchBodyвідповідатиме за відображення результатів пошуку, індикаторів завантаження та помилок.
Давайте створимо search_form.dart.
Наш SearchForm буде StatelessWidget, який відображає віджети _SearchBar та
_SearchBody.
_SearchBar також буде StatefulWidget, тому що йому потрібно підтримувати
свій власний TextEditingController, щоб ми могли відстежувати, що користувач
ввів.
_SearchBody — це StatelessWidget, який відповідатиме за відображення
результатів пошуку, помилок та індикаторів завантаження. Він буде споживачем
GithubSearchBloc.
Якщо наш стан SearchStateSuccess, ми відображаємо _SearchResults, який ми
реалізуємо далі.
_SearchResults — це StatelessWidget, який приймає List<SearchResultItem>
і відображає їх як список _SearchResultItems.
_SearchResultItem — це StatelessWidget, який відповідає за відображення
інформації про один результат пошуку. Він також відповідає за обробку взаємодії
користувача та навігацію за URL-адресою репозиторію при натисканні користувача.
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)), ); }}Ми використовуємо пакет url_launcher для відкриття зовнішніх URL-адрес.
Збираємо все разом
Section titled “Збираємо все разом”Тепер залишилося лише реалізувати наш головний додаток у 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(), ), ), ), ); }}Наш GithubRepository створюється в main та впроваджується в наш App. Наша
SearchForm обгорнута в BlocProvider, який відповідає за ініціалізацію,
закриття та надання доступу до екземпляру GithubSearchBloc для віджета
SearchForm та його нащадків.
Ось і все! Ми успішно реалізували додаток пошуку GitHub у Flutter, використовуючи пакети bloc та flutter_bloc, і ми успішно відокремили наш шар представлення від нашої бізнес-логіки.
Повний вихідний код можна знайти тут.
Нарешті, ми створимо наш додаток AngularDart GitHub Search.
AngularDart GitHub Search
Section titled “AngularDart GitHub Search”AngularDart GitHub Search буде додатком AngularDart, який повторно використовує
моделі, провайдери даних, сховища та блоки з common_github_search для
реалізації пошуку Github.
Налаштування
Section titled “Налаштування”Нам потрібно почати зі створення нового проєкту AngularDart у нашій директорії
github_search на тому ж рівні, що й common_github_search.
stagehand web-angularВи можете встановити stagehand за допомогою:
dart pub global activate stagehandПотім ми можемо замінити вміст pubspec.yaml на:
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.0Форма пошуку
Section titled “Форма пошуку”Так само, як і в нашому додатку Flutter, нам потрібно створити SearchForm з
компонентами SearchBar та SearchBody.
Наш компонент SearchForm реалізовуватиме OnInit та OnDestroy, тому що йому
потрібно створити та закрити GithubSearchBloc.
SearchBarвідповідатиме за отримання введення від користувача.SearchBodyвідповідатиме за відображення результатів пошуку, індикаторів завантаження та помилок.
Давайте створимо 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(); }}GithubRepository впроваджується в SearchFormComponent.
GithubSearchBloc створюється та закривається SearchFormComponent.
Наш шаблон (search_form_component.html) буде виглядати так:
<div> <h1>GitHub Search</h1> <search-bar [githubSearchBloc]="githubSearchBloc"></search-bar> <search-body [state]="$pipe.bloc(githubSearchBloc)"></search-body></div>Далі ми реалізуємо компонент SearchBar.
Панель пошуку
Section titled “Панель пошуку”SearchBar — це компонент, який відповідатиме за отримання введення від
користувача та сповіщення GithubSearchBloc про зміни тексту.
Створіть 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)); }}SearchBarComponent має залежність від GitHubSearchBloc, тому що він
відповідає за сповіщення bloc про події TextChanged.
Далі ми можемо створити 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)"/>Ми закінчили з SearchBar, тепер переходимо до SearchBody.
Тіло пошуку
Section titled “Тіло пошуку”SearchBody — це компонент, який відповідатиме за відображення результатів
пошуку, помилок та індикаторів завантаження. Він буде споживачем
GithubSearchBloc.
Створіть 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 : '';}SearchBodyComponent має залежність від GithubSearchState, який надається
GithubSearchBloc за допомогою пайпу angular_bloc bloc.
Створіть 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>Якщо наш стан isSuccess, ми відображаємо SearchResults. Ми реалізуємо його
далі.
Результати пошуку
Section titled “Результати пошуку”SearchResults — це компонент, який приймає List<SearchResultItem> і
відображає їх як список SearchResultItems.
Створіть 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;}Далі ми створимо 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>Ми використовуємо ngFor для побудови списку компонентів SearchResultItem.
Час реалізувати SearchResultItem.
Елемент результату пошуку
Section titled “Елемент результату пошуку”SearchResultItem — це компонент, який відповідає за відображення інформації
про один результат пошуку. Він також відповідає за обробку взаємодії користувача
та навігацію за URL-адресою репозиторію при натисканні користувача.
Створіть 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;}та відповідний шаблон у 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>Збираємо все разом
Section titled “Збираємо все разом”У нас є всі наші компоненти, і тепер настав час зібрати їх усі разом в нашому
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();}Ми створюємо GithubRepository в AppComponent та впроваджуємо його в
компонент SearchForm.
Ось і все! Ми успішно реалізували додаток пошуку GitHub в AngularDart,
використовуючи пакети bloc та angular_bloc, і ми успішно відокремили наш шар
представлення від нашої бізнес-логіки.
Повний вихідний код можна знайти тут.
Підсумок
Section titled “Підсумок”У цьому посібнику ми створили додаток Flutter та AngularDart, спільно використовуючи всі моделі, провайдери даних та блоки між ними.
Єдине, що нам дійсно довелося написати двічі — це шар представлення (UI), що чудово з точки зору ефективності та швидкості розробки. Крім того, досить поширено, коли веб-додатки та мобільні додатки мають різний користувацький досвід та стилі, і цей підхід дійсно демонструє, наскільки легко створити два додатки, які виглядають абсолютно по-різному, але спільно використовують ті самі шари даних та бізнес-логіки.
Повний вихідний код можна знайти тут.