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

إدارة الحالة باستخدام Riverpod عبر الميزات

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

إدارة الحالة باستخدام Riverpod عبر الميزات

في تطبيق Flutter الحقيقي، نادراً ما تنتمي الحالة إلى شاشة واحدة. حدث مصادقة المستخدم ينعكس على مجموعة التنقل وصفحة الملف الشخصي والتغذية الرئيسية وشريط التطبيق في آنٍ واحد. يحل Riverpod هذه المشكلة بالسماح لك بتعريف المزودين (providers) مرة واحدة وقراءتهم من أي مكان في شجرة الودجات — دون تمرير الخصائص (prop drilling)، دون تعقيدات InheritedWidget، ودون كائن عالمي قابل للتغيير.

يربط هذا الدرس مزودي Riverpod مباشرةً بـطبقة النطاق (domain layer) في تطبيق المشروع النهائي، ويمثّل البيانات غير المتزامنة باستخدام AsyncValue، ويوضح كيف تشترك شاشات متعددة في نفس جزء الحالة عبر مرجع مزود واحد.

مراجعة: تشريح المزود في Riverpod

كل مزود في Riverpod يتبع نفس العقد: يعرض قيمة ويُخطر المستمعين عند تغييرها. أربعة أنواع من المزودين ستستخدمها أكثر في تطبيق غني بالميزات:

  • Provider — قيمة محسوبة للقراءة فقط ومتزامنة (مثل: نسخة مستودع)
  • StateNotifierProvider — حالة قابلة للتغيير تُدار بفئة فرعية من StateNotifier
  • FutureProvider — يلتف حول Future ويعرض AsyncValue<T>
  • StreamProvider — يلتف حول Stream ويعرض AsyncValue<T>
ملاحظة: تُعرَّف جميع المزودين على المستوى الأعلى لملف Dart (خارج أي فئة). يحلّها Riverpod بشكل كسول (lazily) — لا يُنشأ المزود حتى يقرأه شيء ما، ويُتخلص منه عند غياب أي مستمع (ما لم تستخدم keepAlive: true أو ref.keepAlive()).

ربط المزودين بطبقة النطاق

تفصل البنية الجيدة واجهة المستخدم عن منطق الأعمال. تحتوي طبقة النطاق على حالات الاستخدام وواجهات المستودعات؛ تعمل مزودات Riverpod كجسر بين النطاق وواجهة المستخدم. النمط القياسي هو:

  1. أعلن مزود المستودع الذي يُعيد المستودع المحدد.
  2. أعلن مزود المُخطر (notifier) الذي يستدعي طرق المستودع.
  3. تقرأ الودجات مزود المُخطر وتستجيب لحالات AsyncValue.

مزود المستودع ← StateNotifier ← واجهة المستخدم

// ── domain/repositories/task_repository.dart ──────────────────
abstract class TaskRepository {
  Future<List<Task>> fetchAll();
  Future<void> create(Task task);
  Future<void> delete(String id);
}

// ── data/repositories/remote_task_repository.dart ─────────────
class RemoteTaskRepository implements TaskRepository {
  final Dio _dio;
  RemoteTaskRepository(this._dio);

  @override
  Future<List<Task>> fetchAll() async {
    final response = await _dio.get('/tasks');
    return (response.data as List)
        .map((json) => Task.fromJson(json as Map<String, dynamic>))
        .toList();
  }

  @override
  Future<void> create(Task task) async {
    await _dio.post('/tasks', data: task.toJson());
  }

  @override
  Future<void> delete(String id) async {
    await _dio.delete('/tasks/$id');
  }
}

// ── providers/task_providers.dart ─────────────────────────────
final dioProvider = Provider<Dio>((ref) => Dio());

final taskRepositoryProvider = Provider<TaskRepository>((ref) {
  return RemoteTaskRepository(ref.watch(dioProvider));
});

// StateNotifier يقود ميزة قائمة المهام
class TaskListNotifier extends StateNotifier<AsyncValue<List<Task>>> {
  final TaskRepository _repository;

  TaskListNotifier(this._repository) : super(const AsyncValue.loading()) {
    load();
  }

  Future<void> load() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() => _repository.fetchAll());
  }

  Future<void> delete(String id) async {
    await _repository.delete(id);
    await load(); // إعادة التحميل بعد التعديل
  }
}

final taskListProvider =
    StateNotifierProvider<TaskListNotifier, AsyncValue<List<Task>>>((ref) {
  return TaskListNotifier(ref.watch(taskRepositoryProvider));
});

AsyncValue: نمذجة الحالة غير المتزامنة بأمان

AsyncValue<T> هو نوع مغلق (sealed union) بثلاثة متغيرات: loading وdata وerror. بدلاً من الاحتفاظ بثلاثة قيم منطقية منفصلة (isLoading، hasError، data)، تستدعي when() وتعالج كل حالة في تعبير واحد. هذا يجعل من المستحيل نسيان حالة التحميل أو الخطأ في موقع الاستدعاء.

استهلاك AsyncValue بـ .when() في ودجت

class TaskListScreen extends ConsumerWidget {
  const TaskListScreen({super.key});

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

    return Scaffold(
      appBar: AppBar(
        title: const Text('مهامي'),
        actions: [
          // شارة مشتركة عبر الشاشات — نفس المزود، دون تمرير خصائص
          Consumer(
            builder: (context, ref, _) {
              final count = ref.watch(
                taskListProvider.select(
                  (value) => value.asData?.value.length ?? 0,
                ),
              );
              return Badge(label: Text('$count'));
            },
          ),
        ],
      ),
      body: tasksAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              Text('خطأ: $err'),
              ElevatedButton(
                onPressed: () =>
                    ref.read(taskListProvider.notifier).load(),
                child: const Text('إعادة المحاولة'),
              ),
            ],
          ),
        ),
        data: (tasks) => ListView.builder(
          itemCount: tasks.length,
          itemBuilder: (context, index) {
            final task = tasks[index];
            return ListTile(
              title: Text(task.title),
              trailing: IconButton(
                icon: const Icon(Icons.delete),
                onPressed: () =>
                    ref.read(taskListProvider.notifier).delete(task.id),
              ),
            );
          },
        ),
      ),
    );
  }
}

مشاركة الحالة بين الشاشات دون تمرير الخصائص

إحدى أقوى ميزات Riverpod أن أي ودجت في الشجرة يمكنه قراءة أي مزود دون تلقيه كمعامل في المُنشئ. افترض شاشة تفاصيل يجب أن تعكس بيانات المهمة ذاتها التي تعرضها شاشة القائمة، وودجت إحصائيات في لوحة التحكم يعرض إجمالي عدد المهام. تقرأ الثلاثة نفس taskListProvider — يضمن Riverpod مشاركتها لنسخة واحدة.

نصيحة: استخدم ref.watch(provider.select(...)) للاشتراك في جزء فقط من الحالة. هذا يمنع إعادة البناء غير الضرورية عند تغيير أجزاء غير ذات صلة من الحالة — أمر بالغ الأهمية للحفاظ على أداء 60 إطاراً في الثانية في القوائم الطويلة.

مزودو Family: حالة خاصة بكل ميزة

عندما تحتاج كل ميزة لجزء معزول خاص بها من نفس نوع المُخطر، استخدم معدّل .family. مثال شائع هو قوائم المهام الخاصة بكل مشروع في تطبيق إدارة مشاريع:

مزود family لحالة خاصة بكل مشروع

final projectTasksProvider = StateNotifierProvider.family<
    TaskListNotifier,
    AsyncValue<List<Task>>,
    String /* projectId */>((ref, projectId) {
  return TaskListNotifier(
    ref.watch(taskRepositoryProvider),
    projectId: projectId,
  );
});

// في ProjectDetailScreen:
// ref.watch(projectTasksProvider('proj_42')) → حالة معزولة لكل مشروع
تحذير: لا تستدعي ref.read() داخل طريقة build() للحالة التفاعلية — استخدم دائماً ref.watch(). ref.read() مخصص للإجراءات الأمرية ذات الاستدعاء الواحد (مثل: داخل ردود أفعال الأزرار). استخدام ref.read() بشكل تفاعلي سيتخطى إعادة البناء بصمت وينتج واجهة مستخدم قديمة.

الخلاصة

يتيح لك رسم بياني مزودات Riverpod نمذجة الدورة الحياتية الكاملة لميزة غير متزامنة — التحميل والخطأ والبيانات — عبر AsyncValue، مع الإبقاء على منطق النطاق في فئات Dart البسيطة التي يسهل اختبارها. إعلان المزودين على المستوى الأعلى، وربطهم بتجريدات المستودعات، وقراءتهم من أي ودجت يُلغي تمرير الخصائص ويُبقي حالة كل ميزة معزولة ولكن يمكن الوصول إليها عالمياً عند الحاجة.

النقطة الرئيسية: اربط المزودين بواجهات المستودعات، وليس بالتطبيقات المحددة. مثّل جميع الحالات غير المتزامنة بـAsyncValue وعالج المتغيرات الثلاثة بـ.when(). استخدم ref.watch(provider.select(...)) للاشتراكات الدقيقة. استفد من .family للحالة الخاصة بكل كيان. هذه الممارسات الأربع معاً تُنتج حالة ميزات قابلة للصيانة وقابلة للاختبار وعالية الأداء في أي تطبيق Flutter.