Flutter: ممارسة المرحلة 2 — ShopApp النسخة الأساسية
أجزاء متفرقة تُجمع في محرك — المرحلة 2 تُجمع المعرفة المستقلة في بنية تطبيق كاملة.
📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا
- الدرس 1: مقدمة Flutter وإعداد البيئة
- الدرس 2: دورة مكثفة في لغة Dart
- الدرس 3: أساسيات الويدجت
- الدرس 4: نظام التخطيط
- الدرس 5: StatefulWidget والتفاعل
- الدرس 6: Material Design والمكوّنات الشائعة
- الدرس 7: ممارسة المرحلة 1 — ShopApp نسخة البداية
- الدرس 8: التنقل والتوجيه
- الدرس 9: النماذج والإدخال
- الدرس 10: القوائم والتمرير
- الدرس 11: الشبكات وREST API
- الدرس 12: إدارة الحالة — Riverpod
- الدرس 13: التخزين المحلي
1. ما ستتعلمه
- تقسيم البنية: العرض(Widget) ← التطبيق(Notifier) ← البنية التحتية(Dio+Hive)
- حالة Riverpod العامة: تنسيق cartProvider + authProvider + productProvider
- معترضات Dio: إرفاق Bearer Token تلقائيًا + تحديث 401 تلقائي
- تخزين Hive المؤقت دون اتصال: عرض بيانات المنتجات المخزنة مؤقتًا عند فشل الشبكة
- تدفق المستخدم الكامل: تصفح ← إضافة للسلة ← تسجيل دخول ← دفع ← تأكيد الطلب (تسوية بالدولار)
2. قصة حقيقية عن فوضى البنية
(1) المشكلة: كود سباغيتي
أُطلقت نسخة ShopApp بوب للمرحلة 1، لكن الكود فوضوي: طلبات الشبكة متناثرة داخل الويدجتات، إدارة الحالة نصفها setState ونصفها استدعاءات عكسية، منطق التخزين المؤقت مكتوب في دوال build. إضافة ميزة تتطلب تعديل 5 ملفات، وكل إصدار يُخاطر بإدخال أخطاء. تطبيق بـ 100 ألف مستخدم نشط يوميًا لا يستطيع تحمل كود سباغيتي.
(2) حل البنية ثلاثية الطبقات
تُقسم البنية النظيفة الكود إلى ثلاث طبقات: العرض (UI)، التطبيق (منطق الأعمال/الحالة)، والبنية التحتية (مصادر البيانات)، مع تدفق التبعيات من الخارج إلى الداخل.
(3) الفائدة: قابل للصيانة، قابل للاختبار، قابل للتوسعة
بعد إعادة البنية لثلاث طبقات، يُضيف بوب وحدة دفع ببساطة بإضافة Notifier + مستودع، دون لمس طبقة UI. اختبارات الوحدة يمكنها محاكاة المستودع لاختبار Notifiers، مستقلة عن الشبكة.
3. تصميم البنية ثلاثية الطبقات
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
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(1) مسؤوليات الطبقات
| الطبقة | الدليل | المسؤولية | تعتمد على |
|---|---|---|---|
| العرض | screens/, widgets/ |
تصيير UI + تفاعل المستخدم | التطبيق |
| التطبيق | notifiers/, providers/ |
إدارة الحالة + منطق الأعمال | البنية التحتية |
| البنية التحتية | repositories/, storage/, network/ |
جلب البيانات + الاستمرارية | النماذج |
(2) هيكل المشروع
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
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
4. تنفيذ طبقة البنية التحتية
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: غلاف عميل Dio
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);
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: مستودع المنتجات
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);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
5. تنفيذ طبقة التطبيق
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: Notifier المصادقة
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);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: Notifier السلة (نسخة مستمرة)
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(); }
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
6. تنفيذ طبقة العرض
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: حارس مصادقة GoRouter
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()),
],
);
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: الرئيسية تستهلك المزوّدين
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)));
}),
),
),
),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
7. مثال كامل: تدفق بدء main.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,
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
ProviderScope.containerOf(context).read(provider) لقراءة المزوّدين داخل redirect.await Hive.deleteBoxFromDisk('products').📖 ملخص
- البنية ثلاثية الطبقات: العرض ← التطبيق ← البنية التحتية، باتجاه تبعية أحادي
- Riverpod Notifiers تدير منطق الأعمال والحالة؛ المستودعات تدير جلب البيانات
- معترضات Dio تتعامل بشكل موحد مع إرفاق الرمز وتحديث 401
- تخزين Hive المؤقت يُمكن الرجوع دون اتصال، عرض البيانات المخزنة مؤقتًا عند فشل الشبكة
- redirect في GoRouter يُنفذ حراس مصادقة المسارات
📝 تمارين
- أساسي (⭐): أعد بناء مشروع المرحلة 1 باستخدام البنية ثلاثية الطبقات، مستخلصًا طلبات الشبكة من الويدجتات إلى مستودعات.
- متوسط (⭐⭐): أضف Auth Notifier + SecureStorage، منفذًا استمرارية حالة تسجيل الدخول وتحديث 401 تلقائي.
- متقدم (⭐⭐⭐): نفّذ تدفق المستخدم الكامل: تصفح المنتجات (تخزين مؤقت دون اتصال) ← إضافة للسلة (مستمر) ← تسجيل الدخول (تحديث الرمز) ← الدفع (التحقق من النموذج) ← تأكيد الطلب، مع تدهور سليم عند فشل الشبكة في كل خطوة.