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

المصادقة: التسجيل وتسجيل الدخول واستمرارية الجلسة

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

المصادقة: التسجيل وتسجيل الدخول واستمرارية الجلسة

يتطلب تطبيق Flutter الإنتاجي في الغالب طبقة مصادقة آمنة وموثوقة. في هذا الدرس ستقوم بتوصيل Firebase Authentication (بريد إلكتروني وكلمة مرور) بمشروعك التطبيقي باستخدام بنية repository + use-case نظيفة، وستحافظ على الجلسة المصادقة حتى يبقى المستخدمون مسجلين دخولهم عبر إعادة تشغيل التطبيق، وستعرض رسائل خطأ ذات معنى في واجهة المستخدم.

نظرة عامة على البنية

بدلاً من استدعاء أساليب Firebase SDK مباشرةً من الودجات، نفصل المخاوف إلى ثلاث طبقات:

  • AuthRepository — يجرد مصدر البيانات (Firebase). يمكن استبداله أو محاكاته في الاختبارات.
  • حالات الاستخدام (Use Cases) — أغلفة منطق أعمال رفيعة: SignUpUseCase، SignInUseCase، SignOutUseCase، GetCurrentUserUseCase.
  • AuthNotifier (Riverpod) — حامل الحالة الذي يستدعي حالات الاستخدام ويكشف AuthState لشجرة الودجات.
ملاحظة: يعكس هذا النمط التصميم المدفوع بالنطاق (Domain-Driven Design). يبقي الودجات جاهلة بـ Firebase، مما يجعل عمليات الترحيل المستقبلية (مثل التبديل إلى خادم خلفي مخصص) أمراً بسيطاً.

إعداد AuthRepository

عرّف واجهة مجردة وتطبيق Firebase ملموس:

auth_repository.dart

import 'package:firebase_auth/firebase_auth.dart';

// العقد المجرد — الودجات تعتمد على هذا، وليس على الفئة الملموسة
abstract class AuthRepository {
  Stream<User?> get authStateChanges;
  Future<User> signUp({required String email, required String password});
  Future<User> signIn({required String email, required String password});
  Future<void> signOut();
  User? get currentUser;
}

// التطبيق الملموس لـ Firebase
class FirebaseAuthRepository implements AuthRepository {
  final FirebaseAuth _auth;

  FirebaseAuthRepository({FirebaseAuth? auth})
      : _auth = auth ?? FirebaseAuth.instance;

  @override
  Stream<User?> get authStateChanges => _auth.authStateChanges();

  @override
  User? get currentUser => _auth.currentUser;

  @override
  Future<User> signUp({
    required String email,
    required String password,
  }) async {
    final credential = await _auth.createUserWithEmailAndPassword(
      email: email,
      password: password,
    );
    return credential.user!;
  }

  @override
  Future<User> signIn({
    required String email,
    required String password,
  }) async {
    final credential = await _auth.signInWithEmailAndPassword(
      email: email,
      password: password,
    );
    return credential.user!;
  }

  @override
  Future<void> signOut() => _auth.signOut();
}
نصيحة: حقن FirebaseAuth عبر المنشئ (مع قيمة افتراضية هي FirebaseAuth.instance) يجعل اختبار الوحدة سهلاً — مرر نموذجاً مزيفاً في الاختبارات دون لمس كود الإنتاج.

حالات الاستخدام

كل حالة استخدام هي فئة ذات مسؤولية واحدة مع طريقة call() واحدة. تترجم استثناءات Firebase الخام إلى أخطاء نطاق مألوفة:

sign_in_use_case.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'auth_repository.dart';

class SignInUseCase {
  final AuthRepository _repo;
  SignInUseCase(this._repo);

  Future<User> call({
    required String email,
    required String password,
  }) async {
    try {
      return await _repo.signIn(email: email, password: password);
    } on FirebaseAuthException catch (e) {
      // تعيين أكواد FirebaseAuthException إلى رسائل مقروءة
      throw _mapError(e.code);
    }
  }

  String _mapError(String code) {
    switch (code) {
      case 'user-not-found':
        return 'لم يُعثر على حساب لعنوان البريد الإلكتروني هذا.';
      case 'wrong-password':
        return 'كلمة المرور غير صحيحة. يرجى المحاولة مرة أخرى.';
      case 'invalid-email':
        return 'عنوان البريد الإلكتروني غير صالح.';
      case 'user-disabled':
        return 'تم تعطيل هذا الحساب.';
      case 'too-many-requests':
        return 'محاولات كثيرة جداً. يرجى الانتظار والمحاولة مجدداً.';
      default:
        return 'فشل تسجيل الدخول. يرجى المحاولة مجدداً.';
    }
  }
}

استمرارية الجلسة

يحافظ Firebase SDK على رمز المصادقة في التخزين الآمن للجهاز افتراضياً على الأجهزة المحمولة. عند تشغيل التطبيق، تحتاج فقط للتحقق من authStateChanges() — إذا وُجد رمز وكان لا يزال صالحاً، يبث Firebase كائن User فوراً قبل اكتمال أي اتصال بالشبكة. اربط هذا بـ StreamProvider من Riverpod:

auth_providers.dart

import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_repository.dart';
import 'firebase_auth_repository.dart';

// توفير المستودع (تجاوز في الاختبارات)
final authRepositoryProvider = Provider<AuthRepository>(
  (ref) => FirebaseAuthRepository(),
);

// استمرارية الجلسة القائمة على البث: يبث User? عند كل تغيير في المصادقة
final authStateProvider = StreamProvider<User?>((ref) {
  return ref.watch(authRepositoryProvider).authStateChanges;
});

// Notifier لإجراءات التسجيل وتسجيل الدخول والخروج
class AuthNotifier extends AsyncNotifier<User?> {
  late AuthRepository _repo;

  @override
  Future<User?> build() async {
    _repo = ref.watch(authRepositoryProvider);
    return _repo.currentUser;
  }

  Future<void> signIn(String email, String password) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(
      () => SignInUseCase(_repo)(email: email, password: password),
    );
  }

  Future<void> signUp(String email, String password) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(
      () => SignUpUseCase(_repo)(email: email, password: password),
    );
  }

  Future<void> signOut() async {
    state = const AsyncLoading();
    await _repo.signOut();
    state = const AsyncData(null);
  }
}

final authNotifierProvider =
    AsyncNotifierProvider<AuthNotifier, User?>(AuthNotifier.new);

التوجيه بناءً على حالة المصادقة

استمع إلى authStateProvider في جهاز التوجيه الخاص بك (مثلاً go_router) لإعادة توجيه المستخدمين غير المصادقين إلى شاشة تسجيل الدخول وتجاوزها للمصادقين:

  • استخدم استدعاء redirect في GoRouter يقرأ ref.watch(authStateProvider).
  • عندما يبث البث null، أعد التوجيه إلى /login.
  • عندما يبث User، أعد التوجيه إلى /home.
  • أثناء تحميل البث (AsyncLoading)، اعرض شاشة البداية وأعد null لإيقاف التنقل.
تحذير: لا تخزن أبداً كلمات المرور أو رموز Firebase ID الخام في SharedPreferences. يتعامل Firebase مع تخزين الرموز بأمان في سلسلة مفاتيح/مخزن مفاتيح المنصة. خزّن فقط التفضيلات غير الحساسة (مثل اختيار السمة) في SharedPreferences.

معالجة الأخطاء في واجهة المستخدم

اعرض الأخطاء باستخدام AsyncValue.when على حالة notifier. يحمل AsyncError رسالة الاستثناء المعيّن من حالة الاستخدام، جاهزة للعرض في SnackBar أو نص النموذج المضمّن:

  • استخدم ref.listen(authNotifierProvider, ...) للتفاعل مع تغييرات الحالة دون إعادة بناء الودجت بالكامل.
  • عند AsyncError، اعرض ScaffoldMessenger.of(context).showSnackBar(...) مع error.toString().
  • احتفظ بحقول النموذج مملوءة عند الخطأ حتى يتمكن المستخدمون من تصحيح الخطأ فقط.
  • عطّل زر الإرسال أثناء AsyncLoading لمنع التقديم المزدوج.

الملخص

لديك الآن تدفق مصادقة كامل وجاهز للإنتاج. يعزل المستودع Firebase عن منطق الأعمال. تترجم حالات الاستخدام استثناءات SDK إلى رسائل مقروءة للمستخدم. يُغذّي StreamProvider استمرارية الرمز المدمجة في Firebase تلقائياً إلى شجرة الودجات. ويحرس توجيه go_router كل مسار دون تكرار عمليات التحقق من المصادقة في شاشات فردية. تتوسع هذه البنية بنظافة عند إضافة تسجيل الدخول الاجتماعي أو القياسات الحيوية أو خادم خلفي مختلف لاحقاً.