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

بحث GitHub

advanced

في هذا الدليل، سنبني تطبيق بحث GitHub باستخدام Flutter وAngularDart لشرح كيف يمكننا مشاركة طبقة البيانات (data layer) وطبقة منطق الأعمال (business logic layer) بين المشروعين.

demo

demo

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

ستتضمن مكتبة GitHub Search المشتركة النماذج (models)، ومزوّد البيانات (data provider)، وRepository، بالإضافة إلى bloc الذي سنعيد استخدامه بين AngularDart وFlutter.

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

Terminal window
mkdir -p github_search/common_github_search

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

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

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

Terminal window
dart pub get

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

GithubClient سيكون مسؤولًا عن توفير البيانات الخام من GitHub API.

لننشئ 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 مسؤولًا عن تذكّر جميع الاستعلامات السابقة لتجنب تنفيذ طلبات شبكة غير ضرورية إلى GitHub API. هذا يساعد أيضًا في تحسين أداء التطبيق.

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

GithubRepository مسؤول عن إنشاء طبقة تجريد (abstraction) بين طبقة البيانات (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();
}
}

بهذا نكون أكملنا طبقة مزوّد البيانات وطبقة الـ repository، وأصبحنا جاهزين للانتقال إلى طبقة منطق الأعمال.

سيتم إشعار Bloc عندما يكتب المستخدم اسم repository، وسنمثل ذلك عبر حدث 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 لإبلاغ طبقة العرض بحدوث خطأ أثناء جلب repositories.

    • 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 ليتضمن كل dependencies المطلوبة.

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

الآن نثبت dependencies.

Terminal window
flutter pub get

انتهى إعداد المشروع. بما أن common_github_search تحتوي طبقة البيانات وطبقة منطق الأعمال، فكل ما نحتاج لبنائه هو طبقة العرض.

سنحتاج إلى إنشاء نموذج يحتوي widget باسم _SearchBar وwidget باسم _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 مسؤول عن عرض بيانات نتيجة بحث واحدة. وهو أيضًا مسؤول عن التعامل مع تفاعل المستخدم والتنقل إلى رابط repository عند النقر.

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

كل ما تبقى هو تنفيذ التطبيق الرئيسي في 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(),
),
),
),
);
}
}

بهذا نكون نفذنا تطبيق بحث GitHub في Flutter بنجاح باستخدام حزمتَي bloc و flutter_bloc، وتمكنا من فصل طبقة العرض عن طبقة منطق الأعمال.

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

الآن سننتقل إلى بناء تطبيق GitHub Search باستخدام AngularDart.

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

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

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

نبدأ بإنشاء مشروع AngularDart جديد داخل مجلد github_search في نفس مستوى common_github_search.

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

كما في تطبيق Flutter، نحتاج إلى SearchForm يحتوي على مكوّني SearchBar و SearchBody.

مكوّن SearchForm سيطبّق OnInit وOnDestroy لأنه يحتاج لإنشاء GithubSearchBloc ثم إغلاقه.

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

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

وسيكون القالب (search_form_component.html) كالتالي:

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>

الآن سننفذ مكوّن SearchBar.

SearchBar هو مكوّن مسؤول عن استقبال إدخال المستخدم، وإشعار GithubSearchBloc بتغيّر النص.

أنشئ 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> ويعرضها كقائمة من SearchResultItems.

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

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

أنشئ 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 مع مشاركة جميع النماذج، ومزوّدي البيانات، والـ blocs بين المشروعين.

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

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