Flutter: ممارسة المرحلة 2 — ShopApp النسخة الأساسية

أجزاء متفرقة تُجمع في محرك — المرحلة 2 تُجمع المعرفة المستقلة في بنية تطبيق كاملة.

📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا

1. ما ستتعلمه


2. قصة حقيقية عن فوضى البنية

(1) المشكلة: كود سباغيتي

أُطلقت نسخة ShopApp بوب للمرحلة 1، لكن الكود فوضوي: طلبات الشبكة متناثرة داخل الويدجتات، إدارة الحالة نصفها setState ونصفها استدعاءات عكسية، منطق التخزين المؤقت مكتوب في دوال build. إضافة ميزة تتطلب تعديل 5 ملفات، وكل إصدار يُخاطر بإدخال أخطاء. تطبيق بـ 100 ألف مستخدم نشط يوميًا لا يستطيع تحمل كود سباغيتي.

(2) حل البنية ثلاثية الطبقات

تُقسم البنية النظيفة الكود إلى ثلاث طبقات: العرض (UI)، التطبيق (منطق الأعمال/الحالة)، والبنية التحتية (مصادر البيانات)، مع تدفق التبعيات من الخارج إلى الداخل.

(3) الفائدة: قابل للصيانة، قابل للاختبار، قابل للتوسعة

بعد إعادة البنية لثلاث طبقات، يُضيف بوب وحدة دفع ببساطة بإضافة Notifier + مستودع، دون لمس طبقة UI. اختبارات الوحدة يمكنها محاكاة المستودع لاختبار Notifiers، مستقلة عن الشبكة.


3. تصميم البنية ثلاثية الطبقات

100%
graph TD
    subgraph Presentation
        HP2[HomePage]
        DP2[DetailPage]
        CP2[CartPage]
        CK[CheckoutPage]
    end
    subgraph Application Layer
        PN2[ProductNotifier]
        CN2[CartNotifier]
        AN[AuthNotifier]
    end
    subgraph Infrastructure
        DR2[DioRepository]
        HR[HiveCache]
        SS2[SecureStorage]
    end
    HP2 --> PN2
    DP2 --> CN2
    CP2 --> CN2
    CK --> AN
    PN2 --> DR2
    AN --> SS2
    PN2 --> HR
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(1) مسؤوليات الطبقات

الطبقة الدليل المسؤولية تعتمد على
العرض screens/, widgets/ تصيير UI + تفاعل المستخدم التطبيق
التطبيق notifiers/, providers/ إدارة الحالة + منطق الأعمال البنية التحتية
البنية التحتية repositories/, storage/, network/ جلب البيانات + الاستمرارية النماذج

(2) هيكل المشروع

TEXT
lib/
├── main.dart
├── app.dart                    # MaterialApp.router
├── core/
│   ├── router.dart             # GoRouter config
│   ├── network/
│   │   ├── dio_client.dart     # Dio instance + interceptors
│   │   └── api_exception.dart  # Unified error
│   └── storage/
│       ├── secure_storage.dart
│       ├── hive_cache.dart
│       └── shared_prefs.dart
├── models/
│   ├── product.dart
│   ├── cart_item.dart
│   ├── order.dart
│   └── user.dart
├── notifiers/
│   ├── auth_notifier.dart
│   ├── product_notifier.dart
│   └── cart_notifier.dart
├── repositories/
│   ├── product_repository.dart
│   ├── auth_repository.dart
│   └── order_repository.dart
└── screens/
    ├── home_page.dart
    ├── detail_page.dart
    ├── cart_page.dart
    ├── checkout_page.dart
    └── login_page.dart
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

4. تنفيذ طبقة البنية التحتية

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: غلاف عميل Dio

DART
import 'package:dio/dio.dart';

// ⚙️ تثبيت التبعية: flutter pub add dio

// Custom class definition sources:
// - SecureStorage: see Lesson 13 Flutter Secure Storage
// - AuthInterceptor: see Lesson 11 Dio Auth Interceptor

class DioClient {
  late final Dio _dio;

  DioClient({required String baseUrl, required SecureStorage storage}) {
    _dio = Dio(BaseOptions(
      baseUrl: baseUrl,
      connectTimeout: const Duration(seconds: 10),
      receiveTimeout: const Duration(seconds: 30),
    ));
    _dio.interceptors.addAll([
      AuthInterceptor(storage: storage, dio: _dio),
      LogInterceptor(requestBody: true, responseBody: true),
    ]);
  }

  Future<Response> get(String path, {Map<String, dynamic>? queryParams}) =>
      _dio.get(path, queryParameters: queryParams);

  Future<Response> post(String path, {dynamic data}) =>
      _dio.post(path, data: data);

  Future<Response> put(String path, {dynamic data}) =>
      _dio.put(path, data: data);

  Future<Response> delete(String path) => _dio.delete(path);
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: مستودع المنتجات

DART
import 'package:dio/dio.dart';

// ⚙️ تثبيت التبعية: flutter pub add dio

// Custom class definition sources:
// - DioClient: see Lesson 4 Dio client wrapper in this lesson
// - ProductCache: see Lesson 13 Hive Product Cache
// - Product: see Lesson 11 json_serializable model

class ProductRepository {
  final DioClient _client;
  final ProductCache _cache;

  ProductRepository(this._client, this._cache);

  Future<List<Product>> getProducts({int page = 1, String? category}) async {
    try {
      final response = await _client.get('/products', queryParams: {
        'page': page, 'limit': 20, if (category != null) 'category': category,
      });
      final products = (response.data['items'] as List)
          .map((j) => Product.fromJson(j)).toList();
      await _cache.saveProducts(products);
      return products;
    } on DioException {
      // Offline fallback
      final cached = await _cache.getProducts();
      if (cached.isNotEmpty) return cached;
      rethrow;
    }
  }

  Future<Product> getProductById(int id) async {
    final cached = await _cache.getProductById(id);
    if (cached != null) return cached;
    final response = await _client.get('/products/$id');
    return Product.fromJson(response.data);
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. تنفيذ طبقة التطبيق

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: Notifier المصادقة

DART
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

// ⚙️ تثبيت التبعية: flutter pub add flutter_riverpod riverpod_annotation
// ⚙️ تبعية تطوير: flutter pub add --dev riverpod_generator build_runner

// Custom class definition sources:
// - User: see Lesson 12 @riverpod Auth example
// - secureStorageProvider/authRepoProvider: see Lesson 13 storage layer

@riverpod
class Auth extends _$Auth {
  @override
  AsyncValue<User?> build() {
    // Check stored token on app start
    _checkStoredToken();
    return const AsyncData(null);
  }

  Future<void> _checkStoredToken() async {
    final storage = ref.read(secureStorageProvider);
    final token = await storage.getAccessToken();
    if (token != null) {
      state = await AsyncValue.guard(() => ref.read(authRepoProvider).validateToken(token));
    }
  }

  Future<void> login(String email, String password) async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final result = await ref.read(authRepoProvider).login(email, password);
      await ref.read(secureStorageProvider).saveTokens(
        accessToken: result.accessToken, refreshToken: result.refreshToken, userId: result.user.id,
      );
      return result.user;
    });
  }

  Future<void> logout() async {
    await ref.read(secureStorageProvider).clearAll();
    state = const AsyncData(null);
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: Notifier السلة (نسخة مستمرة)

DART
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

// ⚙️ تثبيت التبعية: flutter pub add flutter_riverpod riverpod_annotation
// ⚙️ تبعية تطوير: flutter pub add --dev riverpod_generator build_runner

// Custom class definition sources:
// - CartItem: see Lesson 12 CartNotifier example
// - Product: see Lesson 11 json_serializable model
// - cartStorageProvider: see Lesson 13 Hive cart persistence

@riverpod
class Cart extends _$Cart {
  @override
  List<CartItem> build() {
    // Load from local storage on init
    _loadFromStorage();
    return [];
  }

  Future<void> _loadFromStorage() async {
    final storage = ref.read(cartStorageProvider);
    final items = await storage.loadCart();
    if (items.isNotEmpty) {
      state = items;
    }
  }

  Future<void> _persist() async {
    final storage = ref.read(cartStorageProvider);
    await storage.saveCart(state);
  }

  void addItem(Product product) {
    final idx = state.indexWhere((i) => i.product.id == product.id);
    if (idx >= 0) {
      state = [...state]..[idx] = state[idx].copyWith(quantity: state[idx].quantity + 1);
    } else {
      state = [...state, CartItem(product: product, quantity: 1)];
    }
    _persist();
  }

  void updateQuantity(int productId, int qty) {
    if (qty <= 0) { removeItem(productId); return; }
    state = [for (final i in state) i.product.id == productId ? i.copyWith(quantity: qty) : i];
    _persist();
  }

  void removeItem(int productId) {
    state = state.where((i) => i.product.id != productId).toList();
    _persist();
  }

  void clear() { state = []; _persist(); }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

6. تنفيذ طبقة العرض

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: حارس مصادقة GoRouter

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

// ⚙️ تثبيت التبعية: flutter pub add flutter_riverpod go_router

// Custom class definition sources:
// - authProvider: see Lesson 5 Auth Notifier in this lesson
// - MainScaffold: custom Shell route container Widget

final router = GoRouter(
  initialLocation: '/',
  redirect: (context, state) {
    final authState = ProviderScope.containerOf(context).read(authProvider);
    final isLoggedIn = authState.valueOrNull != null;
    final isLoginRoute = state.matchedLocation == '/login';
    if (!isLoggedIn && !isLoginRoute) return '/login';
    if (isLoggedIn && isLoginRoute) return '/';
    return null;
  },
  routes: [
    ShellRoute(
      builder: (_, __, child) => MainScaffold(child: child),
      routes: [
        GoRoute(path: '/', builder: (_, __) => const HomePage()),
        GoRoute(path: '/cart', builder: (_, __) => const CartPage()),
        GoRoute(path: '/profile', builder: (_, __) => const ProfilePage()),
      ],
    ),
    GoRoute(path: '/product/:id', builder: (_, s) => DetailPage(id: int.parse(s.pathParameters['id']!))),
    GoRoute(path: '/checkout', builder: (_, __) => const CheckoutPage()),
    GoRoute(path: '/login', builder: (_, __) => const LoginPage()),
  ],
);
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

▶ مثال

TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: الرئيسية تستهلك المزوّدين

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

// ⚙️ تثبيت التبعية: flutter pub add flutter_riverpod go_router

// Custom class definition sources:
// - productProvider/cartItemCountProvider/cartProvider: see Lesson 12
// - ProductCard: custom product card Widget

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final productsAsync = ref.watch(productProvider);
    final cartCount = ref.watch(cartItemCountProvider);
    return Scaffold(
      appBar: AppBar(title: const Text('ShopApp'), actions: [
        Badge(count: cartCount, child: IconButton(icon: const Icon(Icons.shopping_cart),
          onPressed: () => context.go('/cart'))),
      ]),
      body: productsAsync.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, _) => Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
          Text('Error: $err'), const SizedBox(height: 16),
          FilledButton(onPressed: () => ref.read(productProvider.notifier).refresh(), child: const Text('Retry')),
        ])),
        data: (products) => RefreshIndicator(
          onRefresh: () => ref.read(productProvider.notifier).refresh(),
          child: GridView.builder(gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2, mainAxisSpacing: 8, crossAxisSpacing: 8, childAspectRatio: 0.7),
            itemCount: products.length,
            itemBuilder: (_, i) => ProductCard(product: products[i], onAddToCart: () {
              ref.read(cartProvider.notifier).addItem(products[i]);
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text('${products[i].name} added to cart'), duration: const Duration(seconds: 1)));
            }),
          ),
        ),
      ),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. مثال كامل: تدفق بدء main.dart

DART
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shared_preferences/shared_preferences.dart';

// ⚙️ تثبيت التبعية: flutter pub add flutter_riverpod shared_preferences

// Custom class definition sources:
// - DioClient: see Lesson 4 in this lesson
// - SecureStorage: see Lesson 13
// - initHive(): see Lesson 13 Hive initialization
// - ShopApp: custom MaterialApp Widget
// - sharedPreferencesProvider/dioClientProvider: Riverpod Provider

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  // Initialize storage
  await initHive();
  final prefs = await SharedPreferences.getInstance();
  final dioClient = DioClient(baseUrl: 'https://api.shopapp.com/v1', storage: SecureStorage());

  runApp(ProviderScope(
    overrides: [
      sharedPreferencesProvider.overrideWithValue(prefs),
      dioClientProvider.overrideWithValue(dioClient),
    ],
    child: const ShopApp(),
  ));
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp.router(
      title: 'ShopApp',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
      routerConfig: router,
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س هل البنية ثلاثية الطبقات مبالغة في الهندسة؟
ج المشاريع الصغيرة يمكنها كتابة المنطق مباشرة في الويدجتات. عندما يتجاوز المشروع 5 صفقات أو لديه مصادر بيانات متعددة، البنية ثلاثية الطبقات تجعل الكود قابلاً للصيانة.
س كيف تنتشر الأخطاء بين المستودع وNotifier؟
ج المستودع يُلقي ApiException، يلتقطه Notifier بـ AsyncValue.guard، وUI يعرضه عبر .when(error:).
س كيف أنفذ انتهاء صلاحية التخزين المؤقت دون اتصال؟
ج خزّن طابعًا زمنيًا مع البيانات في Hive، تحقق عند القراءة مما إذا انتهت الصلاحية (مثلًا ساعة واحدة)، وأعد الجلب إذا انتهت.
س كيف يصل redirect في GoRouter لحالة Riverpod؟
ج استخدم ProviderScope.containerOf(context).read(provider) لقراءة المزوّدين داخل redirect.
س ماذا لو فشلت تهيئة Hive؟
ج غلّف initHive() في try-catch؛ عند الفشل، امسح بيانات Hive وأعد التهيئة: await Hive.deleteBoxFromDisk('products').
س كيف أتعامل مع الطلبات المتزامنة أثناء تحديث الرمز؟
ج استخدم قفل Completer: أول 401 يُشغّل التحديث، وبقية طلبات 401 تنتظر اكتمال Completer قبل إعادة المحاولة، لتجنب تحديثات متعددة.

📖 ملخص


📝 تمارين

  1. أساسي (⭐): أعد بناء مشروع المرحلة 1 باستخدام البنية ثلاثية الطبقات، مستخلصًا طلبات الشبكة من الويدجتات إلى مستودعات.
  2. متوسط (⭐⭐): أضف Auth Notifier + SecureStorage، منفذًا استمرارية حالة تسجيل الدخول وتحديث 401 تلقائي.
  3. متقدم (⭐⭐⭐): نفّذ تدفق المستخدم الكامل: تصفح المنتجات (تخزين مؤقت دون اتصال) ← إضافة للسلة (مستمر) ← تسجيل الدخول (تحديث الرمز) ← الدفع (التحقق من النموذج) ← تأكيد الطلب، مع تدهور سليم عند فشل الشبكة في كل خطوة.

← الدرس السابق | الدرس التالي →

Web-Tutorial.com

فريق Web-Tutorial التقني

منصة دروس برمجية يديرها عدة مطورين. كل درس يتم كتابته ومراجعته بواسطة مطورين متخصصين في المجال. نعمل على ضمان دقة وموثوقية المحتوى — إذا لاحظت أي مشكلة، فيرجى إخبارنا.

100%