Flutter: ممارسة شاملة للمرحلة 1 — ShopApp نسخة البداية
التعلّم بدون ممارسة كشراء المكونات دون طبخ — هذا الدرس يحوّل كل معرفة الدروس الست الأولى إلى وليمة ShopApp كاملة.
📋 المتطلبات السابقة: يجب أن تكون قد أكملت ما يلي أولًا
- الدرس 1: مقدمة في Flutter وإعداد البيئة
- الدرس 2: دورة مكثفة في لغة Dart
- الدرس 3: أساسيات الويدجت
- الدرس 4: نظام التخطيط
- الدرس 5: StatefulWidget والتفاعل
- الدرس 6: Material Design والمكوّنات الشائعة
1. ما ستتعلمه
- تخطيط هيكل المشروع: طبقات models / widgets / screens تحت lib/
- نموذج بيانات المنتج: id / name / price(USD) / imageUrl / rating
- صفحة رئيسية GridView تعرض 1000+ بطاقة منتج (بيانات وهمية)
- صفحة السلة: StatefulWidget يدير كميات المنتجات وحساب السعر الإجمالي
- صفحة تفاصيل المنتج: SliverAppBar + BottomSheet لاختيار المقاس
2. قصة حقيقية من الصفر إلى الواحد
(1) المشكلة: معرفة مجزأة
أنهى بوب الدروس الست الأولى — يستطيع كتابة الويدجت وإنشاء التخطيطات ومعالجة التفاعلات. لكن عند تجميع هذه المعرفة في تطبيق كامل، لم يعرف من أين يبدأ. كيف ينظم الملفات؟ كيف يمرر البيانات؟ كيف يتنقل بين الصفحات؟ كان الأمر كامتلاك قطع أحجية دون القدرة على إكمال الصورة.
(2) الحل القائم على المشروع
ببناء نسخة بداية ShopApp، تُربط المعرفة المجزأة في سلسلة كاملة: نموذج البيانات ← عرض القائمة ← التفاعل ← التنقل بين الصفحات ← إدارة الحالة.
(3) النتيجة: من "يستطيع كتابة مكوّنات" إلى "يستطيع بناء مشاريع"
بعد إكمال نسخة بداية ShopApp، اكتسب بوب القدرة على بناء تطبيقات Flutter كاملة بشكل مستقل. المرحلة 2 تحتاج فقط لاستبدال إدارة الحالة وطبقة الشبكة.
3. تخطيط هيكل المشروع
(1) هيكل الدليل
shop_app/
├── lib/
│ ├── main.dart # App entry point
│ ├── models/
│ │ └── product.dart # Product data model
│ ├── widgets/
│ │ ├── product_card.dart # Reusable product card
│ │ └── quantity_selector.dart
│ ├── screens/
│ │ ├── home_page.dart # Product grid
│ │ ├── detail_page.dart # Product detail
│ │ └── cart_page.dart # Shopping cart
│ └── data/
│ └── mock_products.dart # Mock data generator
├── pubspec.yaml
└── test/
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
(2) مبادئ الطبقات
| الطبقة | المسؤولية | اتجاه التبعية |
|---|---|---|
| models | تعريفات بيانات بحتة، بدون واجهة | لا تبعيات |
| widgets | مكوّنات واجهة قابلة لإعادة الاستخدام | يعتمد على models |
| screens | ويدجت بمستوى الصفحة | يعتمد على models + widgets |
| data | مزودو البيانات (وهمية/API) | يعتمد على models |
| main | مدخل التطبيق + التوجيه | يعتمد على screens |
4. نموذج البيانات والبيانات الوهمية
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: نموذج بيانات المنتج
class Product {
final int id;
final String name;
final double price;
final String imageUrl;
final double rating;
final int reviewCount;
final String category;
const Product({
required this.id,
required this.name,
required this.price,
required this.imageUrl,
this.rating = 0.0,
this.reviewCount = 0,
this.category = 'General',
});
}
سيُستخدم صنف Product هذا في جميع كتل الكود في هذا الدرس.
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: مولّد البيانات الوهمية
// Product class definition shown above
class MockProducts {
static final List<String> _names = [
'Pro Laptop 16"', 'Wireless Earbuds', 'Smart Watch Pro',
'Cotton T-Shirt', 'Running Shoes', 'Backpack Ultra',
'Coffee Maker', 'Desk Lamp LED', 'Yoga Mat Premium',
'Novel Collection', 'Wireless Mouse', 'USB-C Hub',
];
static final List<String> _categories = [
'Electronics', 'Clothing', 'Home', 'Books', 'Sports',
];
static List<Product> generate({int count = 1000}) {
return List.generate(count, (i) => Product(
id: i + 1,
name: _names[i % _names.length],
price: (9.99 + (i % 50) * 10).toDouble(),
imageUrl: 'https://cdn.shopapp.com/products/item_${i + 1}.jpg',
rating: (3.0 + (i % 20) * 0.1),
reviewCount: (i % 5) * 100,
category: _categories[i % _categories.length],
));
}
}
> الإخراج: شغّل محليًا باستخدام 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` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تنفيذ HomePage
import 'package:flutter/material.dart';
// Product class definition shown above
// Simplified class definition
class CartItem {
final Product product;
final int quantity;
const CartItem({required this.product, required this.quantity});
CartItem copyWith({Product? product, int? quantity}) =>
CartItem(product: product ?? this.product, quantity: quantity ?? this.quantity);
}
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final List<Product> _products = MockProducts.generate(count: 1000);
String _selectedCategory = 'All';
List<Product> get _filtered => _selectedCategory == 'All'
? _products
: _products.where((p) => p.category == _selectedCategory).toList();
@override
Widget build(BuildContext context) {
final categories = ['All', ...MockProducts._categories.toSet()];
return Scaffold(
appBar: AppBar(
title: const Text('ShopApp'),
actions: [
Stack(
alignment: Alignment.center,
children: [
IconButton(icon: const Icon(Icons.shopping_cart), onPressed: () => _navigateToCart()),
if (_cartItems.isNotEmpty)
Positioned(right: 4, top: 4,
child: CircleAvatar(radius: 8, backgroundColor: Colors.red,
child: Text('${_cartItems.length}', style: const TextStyle(fontSize: 9, color: Colors.white)))),
],
),
],
),
body: Column(
children: [
// Category filter chips
SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
children: categories.map((cat) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ChoiceChip(
label: Text(cat),
selected: cat == _selectedCategory,
onSelected: (_) => setState(() => _selectedCategory = cat),
),
)).toList(),
),
),
// Product grid
Expanded(
child: GridView.builder(
padding: const EdgeInsets.all(8),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.68,
),
itemCount: _filtered.length,
itemBuilder: (context, index) => ProductCard(
key: ValueKey(_filtered[index].id),
product: _filtered[index],
onAddToCart: () => _addToCart(_filtered[index]),
onTap: () => _navigateToDetail(_filtered[index]),
),
),
),
],
),
);
}
final List<CartItem> _cartItems = [];
void _addToCart(Product product) {
setState(() {
final idx = _cartItems.indexWhere((c) => c.product.id == product.id);
if (idx >= 0) {
_cartItems[idx] = _cartItems[idx].copyWith(quantity: _cartItems[idx].quantity + 1);
} else {
_cartItems.add(CartItem(product: product, quantity: 1));
}
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} added to cart'), duration: const Duration(seconds: 1)),
);
}
void _navigateToCart() {
Navigator.push(context, MaterialPageRoute(
builder: (_) => CartPage(items: _cartItems, onUpdate: (items) => setState(() => _cartItems.clear()..addAll(items))),
));
}
void _navigateToDetail(Product product) {
Navigator.push(context, MaterialPageRoute(
builder: (_) => DetailPage(product: product, onAddToCart: () => _addToCart(product)),
));
}
}
> الإخراج: شغّل محليًا باستخدام 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` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تنفيذ CartPage
import 'package:flutter/material.dart';
// Product class definition shown above
class CartItem {
final Product product;
final int quantity;
const CartItem({required this.product, required this.quantity});
double get total => product.price * quantity;
CartItem copyWith({Product? product, int? quantity}) =>
CartItem(product: product ?? this.product, quantity: quantity ?? this.quantity);
}
class CartPage extends StatefulWidget {
final List<CartItem> items;
final ValueChanged<List<CartItem>> onUpdate;
const CartPage({super.key, required this.items, required this.onUpdate});
@override
State<CartPage> createState() => _CartPageState();
}
class _CartPageState extends State<CartPage> {
late List<CartItem> _items;
@override
void initState() {
super.initState();
_items = List.from(widget.items);
}
double get _total => _items.fold(0.0, (sum, item) => sum + item.total);
void _updateQty(int index, int qty) {
setState(() {
if (qty <= 0) _items.removeAt(index);
else _items[index] = _items[index].copyWith(quantity: qty);
});
widget.onUpdate(_items);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Cart (${_items.length})')),
body: _items.isEmpty
? const Center(child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.shopping_cart_outlined, size: 64, color: Colors.grey),
SizedBox(height: 16),
Text('Your cart is empty', style: TextStyle(fontSize: 18, color: Colors.grey)),
]))
: Column(children: [
Expanded(child: ListView.separated(
itemCount: _items.length,
separatorBuilder: (_, __) => const Divider(),
itemBuilder: (context, i) {
final item = _items[i];
return ListTile(
leading: Image.network(item.product.imageUrl, width: 50, height: 50, fit: BoxFit.cover),
title: Text(item.product.name),
subtitle: Text('$${item.total.toStringAsFixed(2)}'),
trailing: Row(mainAxisSize: MainAxisSize.min, children: [
IconButton(icon: const Icon(Icons.remove), onPressed: () => _updateQty(i, item.quantity - 1)),
Text('${item.quantity}'),
IconButton(icon: const Icon(Icons.add), onPressed: () => _updateQty(i, item.quantity + 1)),
]),
);
},
)),
// Total bar
Container(padding: const EdgeInsets.all(16), color: Colors.grey[100],
child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('Total: $${_total.toStringAsFixed(2)}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
FilledButton(onPressed: () {}, child: const Text('Checkout')),
])),
]),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
7. صفحة تفاصيل المنتج
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: تنفيذ DetailPage
import 'package:flutter/material.dart';
// Product class definition shown above
class DetailPage extends StatelessWidget {
final Product product;
final VoidCallback onAddToCart;
const DetailPage({super.key, required this.product, required this.onAddToCart});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(slivers: [
SliverAppBar(expandedHeight: 300, pinned: true,
flexibleSpace: FlexibleSpaceBar(
title: Text(product.name, style: const TextStyle(fontSize: 14, shadows: [Shadow(color: Colors.black54)])),
background: Image.network(product.imageUrl, fit: BoxFit.cover),
),
),
SliverToBoxAdapter(child: Padding(padding: const EdgeInsets.all(16), child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(product.category, style: TextStyle(color: Colors.grey[600])),
const SizedBox(height: 4),
Row(children: [
Text('$${product.price.toStringAsFixed(2)}', style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: Colors.green)),
const SizedBox(width: 8),
Icon(Icons.star, color: Colors.amber[700], size: 16),
Text(' ${product.rating} (${product.reviewCount})'),
]),
const SizedBox(height: 16),
const Text('Description', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 8),
const Text('Premium quality product. Free shipping on orders over $50. USD settlement supported.'),
],
))),
]),
bottomNavigationBar: SafeArea(child: Padding(padding: const EdgeInsets.all(12), child: Row(children: [
Expanded(child: FilledButton.icon(onPressed: onAddToCart,
icon: const Icon(Icons.shopping_cart), label: const Text('Add to Cart - $${product.price}'))),
]))),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
8. مثال كامل: ShopApp نسخة البداية main.dart
import 'package:flutter/material.dart';
import 'models/product.dart';
import 'data/mock_products.dart';
import 'screens/home_page.dart';
void main() => runApp(const ShopAppStarter);
const ShopAppStarter = ShopApp();
class ShopApp extends StatelessWidget {
const ShopApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'ShopApp Starter',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorSchemeSeed: Colors.blue,
useMaterial3: true,
),
home: const HomePage(),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
model.dart، تُسمّى widgets/ باسم المكوّن.placeholder: AssetImage() أو أيقونات.Navigator.pop(context, result) واستقبل بـ await.📖 ملخص
- طبقات المشروع: models (بيانات) / widgets (مكوّنات) / screens (صفحات) / data (مصادر بيانات)
- البيانات الوهمية للتحقق السريع من واجهة المستخدم، استبدلها بـ API حقيقي لاحقًا
- GridView.builder بالتحميل الكسول يدعم قوائم منتجات بمستوى الألف
- السلة تُدار بـ StatefulWidget + setState، البيانات تُمرر عبر الاستدعاءات
- SliverAppBar يُنشئ تجربة صفحة تفاصيل غامرة
📝 تمارين
- أساسي (الصعوبة ⭐): أنشئ المشروع باتباع هيكل الدرس وتأكد أن HomePage تعرض 20 بطاقة منتج.
- متوسط (الصعوبة ⭐⭐): أضف وظيفة البحث إلى HomePage: أدخل كلمات مفتاحية لتصفية أسماء المنتجات، باستخدام setState لتحديث القائمة.
- متقدم (الصعوبة ⭐⭐⭐): نفّذ تدفق التنقل الكامل بين ثلاث صفحات: الرئيسية ← التفاصيل ← السلة، مع تحديث رقم شارة AppBar تلقائيًا عند العودة من السلة بعد تعديل الكميات.