تخطَّ إلى المحتوى

بحث GitHub

advanced

في هذا الدرس التعليمي، سنقوم ببناء تطبيق بحث GitHub باستخدام Flutter و AngularDart لشرح كيفية مشاركة طبقات البيانات ومنطق العمل بين المشروعين.

demo

demo

  • BlocProvider، ويدجت Flutter التي توفر Bloc لأطفالها.
  • BlocBuilder، ويدجت Flutter التي تدير بناء الويدجت استجابة للحالات الجديدة.
  • استخدام Cubit بدلاً من Bloc. ما الفرق؟
  • منع إعادة البناء غير الضرورية باستخدام Equatable.
  • استخدام محول أحداث مخصص EventTransformer مع bloc_concurrency.
  • إجراء طلبات الشبكة باستخدام حزمة http.

مكتبة البحث المشتركة في GitHub

Section titled “مكتبة البحث المشتركة في GitHub”

تتضمن مكتبة البحث المشتركة في GitHub النماذج، مزود البيانات، المستودع، بالإضافة إلى الـ bloc الذي سيتم مشاركته بين AngularDart و Flutter.

سنبدأ بإنشاء مجلد جديد لتطبيقنا.

Terminal window
mkdir -p github_search/common_github_search

نحتاج إلى إنشاء ملف pubspec.yaml مع التبعيات المطلوبة.

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

وأخيرًا، نحتاج إلى تثبيت التبعيات.

Terminal window
dart pub get

هذا كل ما يتعلق بإعداد المشروع! الآن يمكننا البدء في بناء حزمة common_github_search.

الـ GithubClient هو المسؤول عن توفير البيانات الخام من واجهة برمجة تطبيقات GitHub.

لننشئ الملف 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();
}
}

التالي، نحتاج إلى تعريف نماذج SearchResult و SearchResultError.

أنشئ search_result.dart، والذي يُمثّل قائمة من SearchResultItems تعتمد على استعلام المستخدم:

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;
}

بعدها، سننشئ 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;
}

التالي، سننشئ 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;
}

الآن بعد أن أكملنا تنفيذ SearchResult واعتمادياته، سننتقل إلى SearchResultError.

أنشئ 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;
}

لقد انتهينا من GithubClient، لذلك سننتقل إلى GithubCache، الذي سيكون مسؤولًا عن التخزين المؤقت للنتائج (Memoization) كوسيلة لتحسين الأداء.

سيتولى GithubCache مهمة تذكر جميع الاستعلامات السابقة لتجنب إجراء طلبات شبكة غير ضرورية إلى API GitHub. هذا سيُساعد أيضًا على تحسين أداء التطبيق.

أنشئ 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();
}
}

الآن نحن مستعدون لإنشاء GithubRepository!

مستودع GitHub مسؤول عن خلق طبقة تجريدية بين طبقة البيانات (GithubClient) وطبقة منطق الأعمال (Bloc). هذه الطبقة أيضًا المكان الذي سنستخدم فيه GithubCache.

أنشئ 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();
}
}

الآن بعد أن أكملنا طبقة مزود البيانات وطبقة المستودع، نحن جاهزون للانتقال إلى طبقة منطق الأعمال.

سيتم إعلام الـ Bloc عندما يقوم المستخدم بكتابة اسم مستودع، والذي سنمثله عبر حدث TextChanged من نوع GithubSearchEvent.

أنشئ 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 }';
}

طبقة العرض لدينا ستحتاج إلى مجموعة من المعلومات لكي تعرض الواجهة بشكل صحيح:

  • SearchStateEmpty- تخبر طبقة العرض أنه لم يتم إدخال أي مدخلات من المستخدم.
  • SearchStateLoading- تخبر طبقة العرض بضرورة عرض مؤشر تحميل.
  • SearchStateSuccess- تخبر طبقة العرض بأنها تمتلك بيانات للعرض.
    • items- ستكون قائمة من List<SearchResultItem> التي سيتم عرضها.
  • SearchStateError- تخبر طبقة العرض بحدوث خطأ أثناء جلب البيانات.
    • error- هو الخطأ المحدد الذي حدث.

يمكننا الآن إنشاء github_search_state.dart وتنفيذه كما يلي.

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];
}

الآن بعد أن أنشأنا الأحداث (Events) والحالات (States)، يمكننا إنشاء الـ GithubSearchBloc.

أنشئ 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'),
);
}
}
}

رائع! لقد انتهينا من حزمة common_github_search. المنتج النهائي يجب أن يبدو مثل هذا.

بعد ذلك، سنعمل على تنفيذ Flutter.

تطبيق Flutter GitHub Search سيكون تطبيق Flutter يعيد استخدام النماذج، مزودي البيانات، المستودعات (repositories)، وblocs من مكتبة common_github_search لتنفيذ وظيفة البحث في GitHub.

نبدأ بإنشاء مشروع Flutter جديد داخل مجلد github_search على نفس المستوى الذي توجد به مكتبة common_github_search.

Terminal window
flutter create flutter_github_search

بعدها، نحتاج لتحديث ملف pubspec.yaml ليشمل جميع الاعتمادات اللازمة.

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

الآن، نحتاج لتثبيت الاعتمادات.

Terminal window
flutter pub get

هذا كل شيء بخصوص إعداد المشروع. بما أن مكتبة common_github_search تحتوي على طبقة البيانات وطبقة منطق العمل، ما علينا بناؤه هو فقط طبقة العرض.

سنحتاج لإنشاء نموذج يحتوي على عناصر واجهة _SearchBar و _SearchBody.

  • _SearchBar مسؤول عن استقبال إدخال المستخدم.
  • _SearchBody مسؤول عن عرض نتائج البحث، مؤشرات التحميل، والأخطاء.

لننشئ ملف search_form.dart.

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)),
);
}
}

لقد انتهينا من SearchForm! الآن نحتاج لتنفيذ _SearchBar و _SearchBody.

_SearchBar هو StatefulWidget لأنه سيحتاج إلى إدارة TextEditingController.

flutter_github_search/lib/search_bar.dart
404: Not Found

_SearchBody هو StatelessWidget مسؤول عن عرض نتائج البحث بناءً على حالة الـ Bloc.

flutter_github_search/lib/search_body.dart
404: Not Found

_SearchResults هو StatelessWidget يعرض قائمة من SearchResultItem.

flutter_github_search/lib/search_results.dart
404: Not Found

_SearchResultItem هو StatelessWidget يعرض تفاصيل مستودع واحد ويفتح الرابط عند النقر.

flutter_github_search/lib/search_result_item.dart
404: Not Found

أخيرًا، نحتاج لتعديل 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(),
),
),
),
);
}
}

هذا كل شيء! لقد انتهينا من تطبيق Flutter. الآن سننتقل إلى AngularDart.

بحث GitHub باستخدام AngularDart

Section titled “بحث GitHub باستخدام AngularDart”

تطبيق AngularDart GitHub Search سيعيد استخدام نفس منطق العمل من common_github_search.

أنشئ مشروع AngularDart جديد.

Terminal window
dart pub global activate stagehand
Terminal window
stagehand web-angular

حدث ملف pubspec.yaml.

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

ثبت التبعيات.

Terminal window
flutter pub get

أنشئ 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));
}
}

والقالب 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)"
/>

لقد انتهينا من SearchBar، الآن ننتقل إلى SearchBody.

SearchBody هو مكون مسؤول عن عرض نتائج البحث، الأخطاء، ومؤشرات التحميل. سيكون المستهلك لـ GithubSearchBloc.

أنشئ 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 : '';
}

أنشئ القالب 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>

إذا كانت الحالة isSuccess، نقوم بعرض SearchResults. سنقوم بتنفيذه لاحقًا.

SearchResults هو مكون يستقبل قائمة من List<SearchResultItem> ويعرضها على هيئة قائمة من عناصر SearchResultItem.

أنشئ 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;
}

بعدها، سننشئ القالب 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>

الآن حان الوقت لتنفيذ SearchResultItem.

عنصر نتيجة البحث (Search Result Item)

Section titled “عنصر نتيجة البحث (Search Result Item)”

SearchResultItem هو مكون مسؤول عن عرض المعلومات الخاصة بنتيجة بحث واحدة. وهو مسؤول أيضًا عن التعامل مع تفاعل المستخدم والتنقل إلى رابط المستودع عند نقر المستخدم.

أنشئ 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;
}

والقالب المرتبط به في 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>

لدينا الآن جميع مكوناتنا وحان الوقت لتجميعها كلها معًا في 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();
}

هذا كل شيء! لقد قمنا الآن بنجاح بتنفيذ تطبيق بحث GitHub باستخدام AngularDart مع حزمتي bloc و angular_bloc، وفصلنا بنجاح طبقة العرض عن منطق العمل/الأعمال.

يمكنك إيجاد الشيفرة المصدرية كاملة هنا.

في هذا الدرس، قمنا بإنشاء تطبيق Flutter و AngularDart مع مشاركة جميع النماذج (models)، مزودي البيانات (data providers)، وbloc بين الاثنين.

الشيء الوحيد الذي اضطررنا لكتابته مرتين فعليًا هو طبقة العرض (واجهة المستخدم)، وهو أمر رائع من حيث الكفاءة وسرعة التطوير. بالإضافة إلى ذلك، من الشائع جدًا أن تمتلك تطبيقات الويب وتطبيقات الجوال تجارب مستخدم وأنماط تصميم مختلفة، وتُظهر هذه الطريقة مدى سهولة بناء تطبيقين يبدوان مختلفين تمامًا بينما يشتركان في نفس بيانات ومنطق العمل.

يمكن العثور على المصدر الكامل هنا.