مشروع التخرج: تطبيق Flutter حقيقي

عمليات CRUD في Firestore وطبقة البيانات بدون اتصال

16 دقيقة الدرس 6 من 10

عمليات CRUD في Firestore وطبقة البيانات بدون اتصال

Cloud Firestore هي قاعدة بيانات وثائق NoSQL تمنح تطبيقات Flutter مزامنة بيانات في الوقت الفعلي، ومزامنة سلسة بدون اتصال، ومخططاً مرناً. في مشروع Flutter يتبع البنية النظيفة، تنتمي جميع تفاعلات Firestore إلى طبقة البيانات — تحديداً داخل تطبيق المستودع الذي يستوفي واجهة طبقة المجال. يرشدك هذا الدرس خلال بناء ذلك المستودع من البداية إلى النهاية: تمكين الاستمرارية بدون اتصال، وتطبيق إنشاء/قراءة/تحديث/حذف، وعرض تدفقات تفاعلية تستطيع طبقة العرض استهلاكها دون أن تعرف أي شيء عن Firestore.

إعداد مصدر بيانات Firestore

أضف الاعتمادية وهيّئ Firestore مع تمكين الاستمرارية بدون اتصال قبل runApp. تقوم الاستمرارية بدون اتصال بتخزين الوثائق محلياً حتى تنجح القراءات حتى عندما لا يكون للجهاز اتصال بالشبكة، ويتم تفريغ الكتابات المُنتظرة تلقائياً عند استعادة الاتصال.

main.dart — تهيئة Firestore مع الاستمرارية

import 'package:firebase_core/firebase_core.dart';
import 'package:cloud_firestore/cloud_firestore.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );

  // تمكين الاستمرارية بدون اتصال بحجم غير محدود
  FirebaseFirestore.instance.settings = const Settings(
    persistenceEnabled: true,
    cacheSizeBytes: Settings.CACHE_SIZE_UNLIMITED,
  );

  runApp(const MyApp());
}
ملاحظة: على الويب، تستخدم الاستمرارية بدون اتصال IndexedDB ويجب تمكينها باستدعاء FirebaseFirestore.instance.enablePersistence() بشكل منفصل. على Android وiOS، يكفي persistenceEnabled: true في Settings.

تعريف واجهة المجال

تعلن طبقة المجال عن واجهة مستودع مجردة. توفر طبقة البيانات التطبيق الملموس لـ Firestore. يحافظ هذا الانعكاس على قابلية اختبار منطق الأعمال وإمكانية استبداله — يمكنك استبدال Firestore بخلفية SQLite محلية ببساطة عن طريق تبديل التطبيق المسجل.

domain/repositories/task_repository.dart

import '../entities/task.dart';

abstract class TaskRepository {
  /// يُعيد تدفقاً في الوقت الفعلي لجميع المهام مرتبة حسب وقت الإنشاء.
  Stream<List<Task>> watchAll();

  /// ينشئ مهمة جديدة ويُعيد معرف الوثيقة المُولَّد تلقائياً.
  Future<String> create(Task task);

  /// يحدّث مهمة موجودة تُحدَّد بـ [task.id].
  Future<void> update(Task task);

  /// يحذف نهائياً المهمة ذات المعرف [id].
  Future<void> delete(String id);
}

تطبيق عمليات CRUD في المستودع

يحول تطبيق مستودع Firestore كائنات DocumentSnapshot الخام إلى كيانات مجال مكتوبة والعكس. تُعيد كل عملية كتابة (create وupdate وdelete) Future يُحل عندما تُقرّ Firestore الكتابة — أو فوراً إذا كان الجهاز بدون اتصال (تُنتظر الكتابة محلياً وتُزامن لاحقاً). يُصدر تدفق watchAll قائمة جديدة في كل مرة تتغير فيها المجموعة الأساسية، سواء من ذاكرة التخزين المؤقت المحلية أو الخادم.

data/repositories/firestore_task_repository.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import '../../domain/entities/task.dart';
import '../../domain/repositories/task_repository.dart';

class FirestoreTaskRepository implements TaskRepository {
  final FirebaseFirestore _db;
  static const _collection = 'tasks';

  FirestoreTaskRepository({FirebaseFirestore? db})
      : _db = db ?? FirebaseFirestore.instance;

  CollectionReference<Map<String, dynamic>> get _col =>
      _db.collection(_collection);

  // ── إنشاء ─────────────────────────────────────────────────────────────────
  @override
  Future<String> create(Task task) async {
    final doc = await _col.add(task.toMap());
    return doc.id;
  }

  // ── قراءة (تدفق في الوقت الفعلي) ─────────────────────────────────────────
  @override
  Stream<List<Task>> watchAll() {
    return _col
        .orderBy('createdAt', descending: true)
        .snapshots()
        .map((snapshot) => snapshot.docs
            .map((doc) => Task.fromMap(doc.id, doc.data()))
            .toList());
  }

  // ── تحديث ────────────────────────────────────────────────────────────────
  @override
  Future<void> update(Task task) async {
    await _col.doc(task.id).update(task.toMap());
  }

  // ── حذف ──────────────────────────────────────────────────────────────────
  @override
  Future<void> delete(String id) async {
    await _col.doc(id).delete();
  }
}
نصيحة: يُفضَّل استخدام .snapshots() بدلاً من .get() للبيانات التي تعرضها واجهة المستخدم. يُعيد snapshots() Stream يُصدر قائمة جديدة كلما تغيرت البيانات، مما يمنحك تحديثات في الوقت الفعلي بدون أي كود إضافي. استخدم .get() فقط للقراءات لمرة واحدة حيث لا حاجة للتحديثات المباشرة.

كيان المهمة وتعيين البيانات

كيانات المجال هي فئات Dart عادية — يجب ألا تستورد حزم Firebase. تقع منطق التسلسل في طبقة البيانات عبر منشئات المصنع fromMap وطرق toMap. يضمن استخدام FieldValue.serverTimestamp() لـ createdAt ضبط الطابع الزمني بواسطة خوادم Firestore، وليس ساعة العميل، مما يمنع الانحراف عبر الأجهزة.

domain/entities/task.dart (الكيان) و data/models/task_model.dart (التعيين)

// ── كيان المجال — بدون استيرادات Firebase ────────────────────────────────
class Task {
  final String id;
  final String title;
  final bool isDone;
  final DateTime? createdAt;

  const Task({
    required this.id,
    required this.title,
    this.isDone = false,
    this.createdAt,
  });

  Task copyWith({String? title, bool? isDone}) => Task(
        id: id,
        title: title ?? this.title,
        isDone: isDone ?? this.isDone,
        createdAt: createdAt,
      );

  // ── مساعدات تسلسل Firestore ───────────────────────────────────────────────
  factory Task.fromMap(String id, Map<String, dynamic> map) => Task(
        id: id,
        title: map['title'] as String,
        isDone: map['isDone'] as bool? ?? false,
        createdAt: (map['createdAt'] as Timestamp?)?.toDate(),
      );

  Map<String, dynamic> toMap() => {
        'title': title,
        'isDone': isDone,
        'createdAt': FieldValue.serverTimestamp(),
      };
}
تحذير: لا تخزن أبداً Timestamp أو أي نوع Firestore داخل كيان المجال. حوّل أنواع Firestore (Timestamp وGeoPoint وDocumentReference) إلى أنواع Dart العادية (DateTime وLatLng وString) في fromMap. هذا يُبقي طبقة المجال منفصلة عن Firebase SDK ويجعل اختبار الوحدة أمراً بسيطاً.

توصيل التدفق بطبقة العرض

مع Riverpod (أو أي حل لإدارة الحالة التفاعلية)، يمكنك توصيل تدفق المستودع مباشرة بواجهة المستخدم. يُعيد StreamProvider بناء شجرة الودجات تلقائياً كلما وصلت قائمة جديدة من المهام من Firestore — سواء من ذاكرة التخزين المؤقت المحلية بدون اتصال أو الخادم البعيد — دون أي استدعاءات يدوية لـ setState.

ربط StreamProvider في Riverpod

import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../domain/entities/task.dart';
import '../../domain/repositories/task_repository.dart';
import '../repositories/firestore_task_repository.dart';

// ── حقن التبعية: عرض تطبيق المستودع ──────────────────────────────────────
final taskRepositoryProvider = Provider<TaskRepository>((ref) {
  return FirestoreTaskRepository();
});

// ── تدفق المهام — يُحدَّث تلقائياً في الوقت الفعلي ───────────────────────
final tasksProvider = StreamProvider<List<Task>>((ref) {
  return ref.watch(taskRepositoryProvider).watchAll();
});

// ── في ودجت ───────────────────────────────────────────────────────────────
class TaskListScreen extends ConsumerWidget {
  const TaskListScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final tasksAsync = ref.watch(tasksProvider);

    return tasksAsync.when(
      loading: () => const CircularProgressIndicator(),
      error: (e, _) => Text('خطأ: $e'),
      data: (tasks) => ListView.builder(
        itemCount: tasks.length,
        itemBuilder: (_, i) => ListTile(
          title: Text(tasks[i].title),
          trailing: Icon(
            tasks[i].isDone ? Icons.check_circle : Icons.circle_outlined,
          ),
        ),
      ),
    );
  }
}

ملخص

أصبح لديك الآن طبقة بيانات Firestore offline-first متكاملة تتبع مبادئ البنية النظيفة. النقاط الرئيسية من هذا الدرس:

  • مكّن الاستمرارية بدون اتصال في Settings قبل runApp حتى يعمل التطبيق بدون اتصال.
  • عرّف عقد المستودع في طبقة المجال ونفّذه في طبقة البيانات.
  • استخدم .add() للإنشاء، و.update() للتحديثات الجزئية، و.delete() للحذف، و.snapshots() للقراءات في الوقت الفعلي.
  • أبقِ كيانات المجال خالية من أنواع Firestore؛ نفّذ التسلسل/الترميز في fromMap / toMap.
  • وصّل تدفق المستودع بـ StreamProvider حتى تُعيد واجهة المستخدم البناء بشكل تفاعلي بدون أي مجهود.