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

التنقل والروابط العميقة باستخدام GoRouter

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

التنقل والروابط العميقة باستخدام GoRouter

GoRouter هو حزمة التوجيه التصريحية الموصى بها رسمياً من Flutter. تستبدل واجهة برمجة Navigator منخفضة المستوى بإعداد تصريحي قائم على عناوين URL يدعم الروابط العميقة وعناوين الويب والتنقل المتداخل لعلامات التبويب السفلية وحراسة المسارات — كل ذلك في نموذج متماسك واحد. في هذا الدرس ستُهيئ GoRouter من الصفر وتحمي المسارات بإعادة توجيه المصادقة وتوصّل التنقل المتداخل لشريط تبويبي وتتعامل مع الروابط العميقة القادمة من مصادر خارجية.

١. إعداد GoRouter

أضف التبعية وأنشئ نسخة واحدة من الموجّه، عادةً في ملف مستقل حتى يمكن استيراده في أي مكان دون تبعيات دائرية.

pubspec.yaml وإعداد الموجّه

# pubspec.yaml
dependencies:
  go_router: ^13.2.0

# ─────────────────────────────────────────────
# lib/router/app_router.dart

import 'package:go_router/go_router.dart';
import 'package:flutter/material.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/home',
  routes: [
    GoRoute(
      path: '/login',
      name: 'login',
      builder: (context, state) => const LoginScreen(),
    ),
    GoRoute(
      path: '/home',
      name: 'home',
      builder: (context, state) => const HomeScreen(),
      routes: [
        GoRoute(
          path: 'detail/:id',   // يصبح /home/detail/:id
          name: 'detail',
          builder: (context, state) {
            final id = state.pathParameters['id']!;
            return DetailScreen(itemId: id);
          },
        ),
      ],
    ),
    GoRoute(
      path: '/profile',
      name: 'profile',
      builder: (context, state) => const ProfileScreen(),
    ),
  ],
);

مرّر الموجّه إلى MaterialApp.router بدلاً من MaterialApp العادي:

main.dart — توصيل GoRouter بالتطبيق

import 'package:flutter/material.dart';
import 'router/app_router.dart';

void main() => runApp(const MyApp());

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      title: 'GoRouter Demo',
      routerConfig: appRouter,   // <— نسخة الموجّه الوحيدة
    );
  }
}
ملاحظة: يستبدل GoRouter كلاً من MaterialApp(home:) وMaterialApp(routes:). بمجرد التبديل إلى MaterialApp.router، يجب أن يمر كل تنقل عبر نسخة GoRouter — الجمع بين الأسلوبين يؤدي إلى تعارضات.

٢. المسارات المسمّاة والمعاملات ذات الأنواع

استخدام name: على كل مسار يتيح لك التنقل دون ترميز سلاسل URL بشكل ثابت، مما يمنع الأخطاء المطبعية ويجعل إعادة الهيكلة آمنة.

  • context.goNamed('detail', pathParameters: {'id': '42'}) — يستبدل إدخال المكدس الحالي
  • context.pushNamed('detail', pathParameters: {'id': '42'}) — يدفع فوق المكدس
  • state.pathParameters['id'] — يقرأ مقطع URL المعلَن كـ :id
  • state.uri.queryParameters['q'] — يقرأ سلسلة استعلام مثل ?q=flutter

٣. حراسة المسارات — حماية المسارات التي تتطلب مصادقة

دالة الاسترجاع redirect الخاصة بـ GoRouter تعمل قبل كل حدث تنقل. إعادة مسار جديد تُعيد توجيه المستخدم؛ أما إعادة null فتسمح بالتنقل للمضي قُدُماً. أنظف نمط هو الاستماع إلى تدفق المصادقة عبر refreshListenable حتى تُعيد التغييرات في حالة تسجيل الدخول تقييم جميع عمليات إعادة التوجيه تلقائياً.

حارس المصادقة مع refreshListenable

import 'package:flutter_riverpod/flutter_riverpod.dart';

// ChangeNotifier يلتف حول حالة المصادقة
class AuthNotifier extends ChangeNotifier {
  bool _isLoggedIn = false;
  bool get isLoggedIn => _isLoggedIn;

  void login()  { _isLoggedIn = true;  notifyListeners(); }
  void logout() { _isLoggedIn = false; notifyListeners(); }
}

final authNotifier = AuthNotifier();

// ── الموجّه مع إعادة توجيه عالمية ──────────────────────────────
final GoRouter appRouter = GoRouter(
  initialLocation: '/home',
  refreshListenable: authNotifier,   // يُعيد تشغيل redirect عند تغيير المصادقة
  redirect: (context, state) {
    final loggedIn    = authNotifier.isLoggedIn;
    final onLoginPage = state.matchedLocation == '/login';

    if (!loggedIn && !onLoginPage) return '/login';
    if (loggedIn  &&  onLoginPage) return '/home';
    return null;   // السماح بالتنقل
  },
  routes: [ /* ... */ ],
);
نصيحة: يقبل refreshListenable أي Listenable، لذا يمكنك تمرير ProviderListenable من Riverpod أو ValueNotifier أو دمج عدة مُبلِّغات بـ Listenable.merge([a, b]).

٤. التنقل المتداخل مع ShellRoute (علامات التبويب السفلية)

يلف ShellRoute مجموعة من المسارات داخل ودجت صدفة دائمة — مثالي لأشرطة التنقل السفلية حيث تتشارك علامات التبويب هيكلاً مشتركاً لكن كل تبويب يحتفظ بمكدس التنقل الخاص به.

ShellRoute للتنقل التبويبي

import 'package:go_router/go_router.dart';

final GoRouter appRouter = GoRouter(
  initialLocation: '/feed',
  routes: [
    ShellRoute(
      builder: (context, state, child) => AppShell(child: child),
      routes: [
        GoRoute(
          path: '/feed',
          name: 'feed',
          builder: (_, __) => const FeedScreen(),
          routes: [
            GoRoute(
              path: 'post/:postId',
              name: 'post',
              builder: (context, state) => PostScreen(
                postId: state.pathParameters['postId']!,
              ),
            ),
          ],
        ),
        GoRoute(
          path: '/search',
          name: 'search',
          builder: (_, __) => const SearchScreen(),
        ),
        GoRoute(
          path: '/profile',
          name: 'profile',
          builder: (_, __) => const ProfileScreen(),
        ),
      ],
    ),
  ],
);

// ودجت الصدفة التي تحتوي على BottomNavigationBar
class AppShell extends StatelessWidget {
  final Widget child;
  const AppShell({super.key, required this.child});

  static const _tabs = ['/feed', '/search', '/profile'];

  @override
  Widget build(BuildContext context) {
    final location = GoRouterState.of(context).matchedLocation;
    final index    = _tabs.indexWhere((t) => location.startsWith(t));

    return Scaffold(
      body: child,
      bottomNavigationBar: BottomNavigationBar(
        currentIndex: index < 0 ? 0 : index,
        onTap: (i) => context.go(_tabs[i]),
        items: const [
          BottomNavigationBarItem(icon: Icon(Icons.home),   label: 'Feed'),
          BottomNavigationBarItem(icon: Icon(Icons.search), label: 'Search'),
          BottomNavigationBarItem(icon: Icon(Icons.person), label: 'Profile'),
        ],
      ),
    );
  }
}

٥. التعامل مع الروابط العميقة من مصادر خارجية

الروابط العميقة هي عناوين URL تفتح التطبيق وتنتقل مباشرةً إلى شاشة محددة — مثل إشعار دفع يفتح myapp://post/123، أو رابط ويب يشغّل بناء Flutter الويب على https://myapp.com/post/123. يتعامل GoRouter مع كليهما تلقائياً بمجرد تهيئة مخطط URL في ملفات المنصة.

  • Android: أضف <intent-filter> بالمخطط الخاص بك في AndroidManifest.xml
  • iOS: أضف إدخال CFBundleURLTypes (مخطط مخصص) أو Associated Domains (روابط عالمية) في Info.plist
  • Flutter Web: يعمل فور الاستخدام — يقرأ GoRouter مسار URL المتصفح تلقائياً
تحذير: عندما يصل رابط عميق إلى مسار محمي (مثل /profile) والمستخدم غير مصادق، يجب أن تُعيد دالة الاسترجاع redirect التوجيه إلى /login وتحفظ المسار المطلوب أصلاً حتى تتمكن من إعادة التوجيه إليه بعد تسجيل الدخول. احفظه في معامل الاستعلام: أعد التوجيه إلى /login?redirect=/profile، ثم اقرأ state.uri.queryParameters['redirect'] بعد نجاح تسجيل الدخول.

خلاصة

يجلب GoRouter التنقل التصريحي القائم على URL إلى Flutter. المفاهيم الأساسية هي: نسخة GoRouter وحيدة مع GoRoute مسمّاة، ودالة redirect عالمية مدعومة بـ refreshListenable لحراسة المصادقة، وShellRoute لصدفات التبويب السفلي الدائمة مع مكدسات متداخلة، ومعالجة روابط عميقة بدون إعداد يدوي بمجرد تسجيل مخططات URL للمنصة. هذه اللبنات الأساسية كافية لأي بنية تنقل على مستوى الإنتاج.