Flutter: ممارسة شاملة للمرحلة 1 — ShopApp نسخة البداية

التعلّم بدون ممارسة كشراء المكونات دون طبخ — هذا الدرس يحوّل كل معرفة الدروس الست الأولى إلى وليمة ShopApp كاملة.

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

1. ما ستتعلمه


2. قصة حقيقية من الصفر إلى الواحد

(1) المشكلة: معرفة مجزأة

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

(2) الحل القائم على المشروع

ببناء نسخة بداية ShopApp، تُربط المعرفة المجزأة في سلسلة كاملة: نموذج البيانات ← عرض القائمة ← التفاعل ← التنقل بين الصفحات ← إدارة الحالة.

(3) النتيجة: من "يستطيع كتابة مكوّنات" إلى "يستطيع بناء مشاريع"

بعد إكمال نسخة بداية ShopApp، اكتسب بوب القدرة على بناء تطبيقات Flutter كاملة بشكل مستقل. المرحلة 2 تحتاج فقط لاستبدال إدارة الحالة وطبقة الشبكة.


3. تخطيط هيكل المشروع

(1) هيكل الدليل

TEXT
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/
TEXT
> الإخراج: شغّل محليًا باستخدام 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. نموذج البيانات والبيانات الوهمية

▶ مثال

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

: نموذج بيانات المنتج

DART
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 هذا في جميع كتل الكود في هذا الدرس.

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
// 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],
    ));
  }
}
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` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: تنفيذ HomePage

DART
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)),
    ));
  }
}
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` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: تنفيذ CartPage

DART
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')),
                ])),
            ]),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. صفحة تفاصيل المنتج

▶ مثال

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

: تنفيذ DetailPage

DART
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}'))),
      ]))),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

8. مثال كامل: ShopApp نسخة البداية main.dart

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

❓ أسئلة شائعة

س مع كثرة ملفات المشروع، من السهل الخطأ في مسارات الاستيراد؟
ج استخدم الإكمال التلقائي للمسارات النسبية في VS Code. الاتفاقية: تنتهي ملفات models/ بـ model.dart، تُسمّى widgets/ باسم المكوّن.
س بيانات السلة الممررة عبر setState لصفحة أخرى تُفقد عند العودة؟
ج حاليًا نستخدم دوال الاستدعاء. المرحلة 2 ستستخدم إدارة الحالة الشاملة Riverpod لحل هذا.
س هل سيتأخر GridView بـ 1000 عنصر؟
ج GridView.builder يحمّل كسولًا العناصر المرئية فقط — 1000 عنصر ليس مشكلة. إذا كان كل عنصر معقدًا، فكر في تحسين itemExtent.
س صور البيانات الوهمية لا تُحمّل؟
ج الأمثلة تستخدم عناوين URL مؤقتة. في التطوير الفعلي، استبدلها بـ placeholder: AssetImage() أو أيقونات.
س كيف أمرر معاملات مع التنقل عبر Navigator.push والعودة؟
ج مرر عبر المنشئ عند التنقل؛ أرجع عبر Navigator.pop(context, result) واستقبل بـ await.
س هل يمكن لكود المرحلة 1 التطور مباشرة إلى المرحلة 2؟
ج نعم. طبقة نموذج البيانات والويدجت تبقى كما هي تقريبًا — فقط استبدل setState بـ Riverpod Provider.

📖 ملخص


📝 تمارين

  1. أساسي (الصعوبة ⭐): أنشئ المشروع باتباع هيكل الدرس وتأكد أن HomePage تعرض 20 بطاقة منتج.
  2. متوسط (الصعوبة ⭐⭐): أضف وظيفة البحث إلى HomePage: أدخل كلمات مفتاحية لتصفية أسماء المنتجات، باستخدام setState لتحديث القائمة.
  3. متقدم (الصعوبة ⭐⭐⭐): نفّذ تدفق التنقل الكامل بين ثلاث صفحات: الرئيسية ← التفاصيل ← السلة، مع تحديث رقم شارة AppBar تلقائيًا عند العودة من السلة بعد تعديل الكميات.

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

Web-Tutorial.com

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

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

100%