المشروع التطبيقي: بناء ميزة متكاملة من البداية إلى النهاية
المشروع التطبيقي: بناء ميزة متكاملة من البداية إلى النهاية
يجمع هذا الدرس التطبيقي كل مفهوم من مفاهيم الدرس — Clean Architecture وMVVM ونمط Repository وinjectable لحقن التبعيات والبنية المعيارية للميزات — في تمرين واحد متسق. ستنفذ ميزة المقالات المحفوظة التي تتيح للمستخدم وضع إشارة مرجعية على المقالات وعرض قائمة المحفوظات، مع تغطية كل طبقة من طبقات البنية من Domain إلى Presentation.
الخطوة 1 — طبقة Domain: الكيان وحالة الاستخدام
ابدأ في الطبقة الداخلية الأعمق. عرّف كيان Dart النقي وواجهة حالة الاستخدام — بدون استيراد Flutter ولا تبعيات إطار العمل.
lib/features/saved_articles/domain/entities/saved_article.dart
// كيان Dart نقي — بدون Flutter ولا إطار عمل
class SavedArticle {
final String id;
final String title;
final String url;
final DateTime savedAt;
const SavedArticle({
required this.id,
required this.title,
required this.url,
required this.savedAt,
});
}
// lib/features/saved_articles/domain/repositories/saved_article_repository.dart
abstract class SavedArticleRepository {
Future<List<SavedArticle>> getSavedArticles();
Future<void> saveArticle(SavedArticle article);
Future<void> removeArticle(String id);
}
// lib/features/saved_articles/domain/usecases/get_saved_articles.dart
import 'package:injectable/injectable.dart';
@injectable
class GetSavedArticles {
final SavedArticleRepository _repository;
const GetSavedArticles(this._repository);
Future<List<SavedArticle>> call() => _repository.getSavedArticles();
}
// lib/features/saved_articles/domain/usecases/save_article.dart
@injectable
class SaveArticle {
final SavedArticleRepository _repository;
const SaveArticle(this._repository);
Future<void> call(SavedArticle article) => _repository.saveArticle(article);
}
لاحظ أن الواجهة المجردة SavedArticleRepository تقع في طبقة Domain. فقط الواجهة تنتمي إلى هنا — التنفيذ الملموس هو مسؤولية طبقة Data.
الخطوة 2 — طبقة Data: النموذج ومصدر البيانات والمستودع
تحرك للخارج إلى طبقة Data. أنشئ SavedArticleModel الذي يمتد من الكيان ويضيف منطق التسلسل، وصل LocalDataSource المدعوم بـ shared_preferences، ونفّذ المستودع.
lib/features/saved_articles/data/models/saved_article_model.dart
import 'dart:convert';
import '../../domain/entities/saved_article.dart';
class SavedArticleModel extends SavedArticle {
const SavedArticleModel({
required super.id,
required super.title,
required super.url,
required super.savedAt,
});
factory SavedArticleModel.fromJson(Map<String, dynamic> json) =>
SavedArticleModel(
id: json['id'] as String,
title: json['title'] as String,
url: json['url'] as String,
savedAt: DateTime.parse(json['savedAt'] as String),
);
Map<String, dynamic> toJson() => {
'id': id,
'title': title,
'url': url,
'savedAt': savedAt.toIso8601String(),
};
}
// lib/features/saved_articles/data/datasources/saved_article_local_datasource.dart
import 'package:injectable/injectable.dart';
import 'package:shared_preferences/shared_preferences.dart';
abstract class SavedArticleLocalDataSource {
Future<List<SavedArticleModel>> getSavedArticles();
Future<void> saveArticle(SavedArticleModel model);
Future<void> removeArticle(String id);
}
@LazySingleton(as: SavedArticleLocalDataSource)
class SavedArticleLocalDataSourceImpl implements SavedArticleLocalDataSource {
static const _key = 'saved_articles';
final SharedPreferences _prefs;
SavedArticleLocalDataSourceImpl(this._prefs);
@override
Future<List<SavedArticleModel>> getSavedArticles() async {
final raw = _prefs.getStringList(_key) ?? [];
return raw
.map((s) => SavedArticleModel.fromJson(jsonDecode(s) as Map<String, dynamic>))
.toList();
}
@override
Future<void> saveArticle(SavedArticleModel model) async {
final list = await getSavedArticles();
if (list.any((a) => a.id == model.id)) return;
list.add(model);
await _prefs.setStringList(
_key, list.map((a) => jsonEncode(a.toJson())).toList());
}
@override
Future<void> removeArticle(String id) async {
final list = await getSavedArticles();
list.removeWhere((a) => a.id == id);
await _prefs.setStringList(
_key, list.map((a) => jsonEncode(a.toJson())).toList());
}
}
// lib/features/saved_articles/data/repositories/saved_article_repository_impl.dart
import 'package:injectable/injectable.dart';
@LazySingleton(as: SavedArticleRepository)
class SavedArticleRepositoryImpl implements SavedArticleRepository {
final SavedArticleLocalDataSource _local;
SavedArticleRepositoryImpl(this._local);
@override
Future<List<SavedArticle>> getSavedArticles() => _local.getSavedArticles();
@override
Future<void> saveArticle(SavedArticle article) =>
_local.saveArticle(SavedArticleModel(
id: article.id,
title: article.title,
url: article.url,
savedAt: article.savedAt,
));
@override
Future<void> removeArticle(String id) => _local.removeArticle(id);
}
الخطوة 3 — طبقة Presentation: النموذج العرضي والودجت
تتحدث طبقة Presentation فقط مع حالات الاستخدام — وليس مباشرة مع المستودع أو مصادر البيانات. استخدم ChangeNotifier (أو حل إدارة الحالة الذي تختاره) كنموذج عرضي.
lib/features/saved_articles/presentation/viewmodels/saved_articles_viewmodel.dart
import 'package:flutter/foundation.dart';
import 'package:injectable/injectable.dart';
enum SavedArticlesStatus { initial, loading, loaded, error }
@injectable
class SavedArticlesViewModel extends ChangeNotifier {
final GetSavedArticles _getSavedArticles;
final SaveArticle _saveArticle;
SavedArticlesViewModel(this._getSavedArticles, this._saveArticle);
SavedArticlesStatus status = SavedArticlesStatus.initial;
List<SavedArticle> articles = [];
String? errorMessage;
Future<void> load() async {
status = SavedArticlesStatus.loading;
notifyListeners();
try {
articles = await _getSavedArticles();
status = SavedArticlesStatus.loaded;
} catch (e) {
errorMessage = e.toString();
status = SavedArticlesStatus.error;
}
notifyListeners();
}
Future<void> bookmark(SavedArticle article) async {
await _saveArticle(article);
await load(); // تحديث القائمة
}
}
// lib/features/saved_articles/presentation/pages/saved_articles_page.dart
class SavedArticlesPage extends StatefulWidget {
const SavedArticlesPage({super.key});
@override
State<SavedArticlesPage> createState() => _SavedArticlesPageState();
}
class _SavedArticlesPageState extends State<SavedArticlesPage> {
late final SavedArticlesViewModel _vm;
@override
void initState() {
super.initState();
_vm = getIt<SavedArticlesViewModel>();
_vm.load();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('المقالات المحفوظة')),
body: AnimatedBuilder(
animation: _vm,
builder: (_, __) {
if (_vm.status == SavedArticlesStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (_vm.status == SavedArticlesStatus.error) {
return Center(child: Text(_vm.errorMessage ?? 'خطأ'));
}
if (_vm.articles.isEmpty) {
return const Center(child: Text('لا توجد مقالات محفوظة بعد.'));
}
return ListView.builder(
itemCount: _vm.articles.length,
itemBuilder: (_, i) => ListTile(
title: Text(_vm.articles[i].title),
subtitle: Text(_vm.articles[i].url),
),
);
},
),
);
}
@override
void dispose() {
_vm.dispose();
super.dispose();
}
}
getIt<SavedArticlesViewModel>() يعتمد على تسجيل injectable للنموذج العرضي كـ factory (وليس singleton) حتى تحصل كل صفحة على نسخة جديدة. رُمّزه بـ @injectable (الذي يعمل كـ factory بشكل افتراضي) وليس بـ @LazySingleton.الخطوة 4 — ربط حقن التبعيات
شغّل build runner مرة واحدة لإعادة توليد وحدة الحقن، ثم سجّل SharedPreferences كتبعية خارجية في صف @module قبل استدعاء configureDependencies() في main().
lib/injection.dart (مقتطف)
// بعد إضافة صفوف @injectable / @LazySingleton جديدة، أعد التوليد:
// dart run build_runner build --delete-conflicting-outputs
@module
abstract class RegisterModule {
@preResolve
Future<SharedPreferences> get prefs => SharedPreferences.getInstance();
}
build_runner بعد تعليق صف جديد هو الخطأ الأكثر شيوعاً. ملف *.config.dart المولّد سيكون قديماً وسيرمي getIt خطأ not registered في وقت التشغيل.الخطوة 5 — هيكل مجلد وحدة الميزة
كل ميزة في مشروع Clean Architecture المعياري تتبع نفس اتفاقية المجلد:
lib/features/saved_articles/domain/entities/lib/features/saved_articles/domain/repositories/(واجهات مجردة فقط)lib/features/saved_articles/domain/usecases/lib/features/saved_articles/data/models/lib/features/saved_articles/data/datasources/lib/features/saved_articles/data/repositories/(تنفيذ ملموس)lib/features/saved_articles/presentation/viewmodels/lib/features/saved_articles/presentation/pages/lib/features/saved_articles/presentation/widgets/
هذا الهيكل يعني أن كل طبقة يمكن التنقل فيها باستقلالية؛ يستطيع عضو فريق جديد فتح أي مجلد ميزة وفهم مسؤولياتها فوراً دون قراءة أي طبقة أخرى.
الخلاصة
لقد نفّذت الآن ميزة متكاملة من البداية إلى النهاية: كيان SavedArticle وواجهة المستودع يقطنان بحتاً في Domain؛ النموذج ومصدر البيانات والتنفيذ الملموس للمستودع يقطنان في Data؛ والنموذج العرضي والودجت يقطنان في Presentation. يربط حقن التبعيات كل صف دون أن تمتلك أي طبقة استيراداً مباشراً لنوع ملموس من طبقة أخرى. هذا هو الانضباط الذي يجعل قواعد كود Flutter الكبيرة قابلة للصيانة والاختبار والتطوير.