بحث 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
Section titled “عميل GitHub”الـ GithubClient هو المسؤول عن توفير البيانات الخام من
واجهة برمجة تطبيقات GitHub.
لننشئ الملف 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، الذي سيكون مسؤولًا
عن
التخزين المؤقت للنتائج (Memoization)
كوسيلة لتحسين الأداء.
التخزين المؤقت لـ GitHub
Section titled “التخزين المؤقت لـ GitHub”سيتولى GithubCache مهمة تذكر جميع الاستعلامات السابقة لتجنب إجراء طلبات شبكة
غير ضرورية إلى API GitHub. هذا سيُساعد أيضًا على تحسين أداء التطبيق.
أنشئ 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
Section titled “مستودع GitHub”مستودع GitHub مسؤول عن خلق طبقة تجريدية بين طبقة البيانات (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
Section titled “حدث بحث GitHub”سيتم إعلام الـ 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
Section titled “حالة بحث GitHub”طبقة العرض لدينا ستحتاج إلى مجموعة من المعلومات لكي تعرض الواجهة بشكل صحيح:
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];}الآن بعد أن أنشأنا الأحداث (Events) والحالات (States)، يمكننا إنشاء الـ
GithubSearchBloc.
Bloc بحث GitHub
Section titled “Bloc بحث GitHub”أنشئ 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.
بحث GitHub باستخدام Flutter
Section titled “بحث GitHub باستخدام Flutter”تطبيق Flutter GitHub Search سيكون تطبيق Flutter يعيد استخدام النماذج، مزودي
البيانات، المستودعات (repositories)، وblocs من مكتبة 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.
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)), ); }}لقد انتهينا من SearchForm! الآن نحتاج لتنفيذ _SearchBar و _SearchBody.
شريط البحث
Section titled “شريط البحث”_SearchBar هو StatefulWidget لأنه سيحتاج إلى إدارة TextEditingController.
404: Not Foundجسم البحث
Section titled “جسم البحث”_SearchBody هو StatelessWidget مسؤول عن عرض نتائج البحث بناءً على حالة الـ
Bloc.
404: Not Foundنتائج البحث
Section titled “نتائج البحث”_SearchResults هو StatelessWidget يعرض قائمة من SearchResultItem.
404: Not Foundعنصر نتيجة البحث
Section titled “عنصر نتيجة البحث”_SearchResultItem هو StatelessWidget يعرض تفاصيل مستودع واحد ويفتح الرابط
عند النقر.
404: Not Foundتجميع كل شيء معًا
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(), ), ), ), ); }}هذا كل شيء! لقد انتهينا من تطبيق Flutter. الآن سننتقل إلى AngularDart.
بحث GitHub باستخدام AngularDart
Section titled “بحث GitHub باستخدام AngularDart”تطبيق AngularDart GitHub Search سيعيد استخدام نفس منطق العمل من
common_github_search.
الإعداد
Section titled “الإعداد”أنشئ مشروع AngularDart جديد.
dart pub global activate stagehandstagehand web-angularحدث ملف 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ثبت التبعيات.
flutter pub getشريط البحث (Search Bar)
Section titled “شريط البحث (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)); }}والقالب 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.
جسم البحث (Search Body)
Section titled “جسم البحث (Search Body)”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 : '';}أنشئ القالب 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. سنقوم بتنفيذه لاحقًا.
نتائج البحث (Search Results)
Section titled “نتائج البحث (Search Results)”SearchResults هو مكون يستقبل قائمة من List<SearchResultItem> ويعرضها على
هيئة قائمة من عناصر SearchResultItem.
أنشئ 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>الآن حان الوقت لتنفيذ SearchResultItem.
عنصر نتيجة البحث (Search Result Item)
Section titled “عنصر نتيجة البحث (Search Result Item)”SearchResultItem هو مكون مسؤول عن عرض المعلومات الخاصة بنتيجة بحث واحدة. وهو
مسؤول أيضًا عن التعامل مع تفاعل المستخدم والتنقل إلى رابط المستودع عند نقر
المستخدم.
أنشئ 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();}هذا كل شيء! لقد قمنا الآن بنجاح بتنفيذ تطبيق بحث GitHub باستخدام AngularDart مع
حزمتي bloc و angular_bloc، وفصلنا بنجاح طبقة العرض عن منطق العمل/الأعمال.
يمكنك إيجاد الشيفرة المصدرية كاملة هنا.
الملخص
Section titled “الملخص”في هذا الدرس، قمنا بإنشاء تطبيق Flutter و AngularDart مع مشاركة جميع النماذج (models)، مزودي البيانات (data providers)، وbloc بين الاثنين.
الشيء الوحيد الذي اضطررنا لكتابته مرتين فعليًا هو طبقة العرض (واجهة المستخدم)، وهو أمر رائع من حيث الكفاءة وسرعة التطوير. بالإضافة إلى ذلك، من الشائع جدًا أن تمتلك تطبيقات الويب وتطبيقات الجوال تجارب مستخدم وأنماط تصميم مختلفة، وتُظهر هذه الطريقة مدى سهولة بناء تطبيقين يبدوان مختلفين تمامًا بينما يشتركان في نفس بيانات ومنطق العمل.
يمكن العثور على المصدر الكامل هنا.