Flutter: النماذج والإدخال

النماذج هي الجسر بين المستخدمين وتطبيقك — التحقق المتساهل يُدخل بيانات سيئة؛ وتجربة المستخدم السيئة تُبعد المستخدمين.

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

1. ما ستتعلمه


2. قصة حقيقية عن نموذج دفع فاشل

(1) المشكلة: بيانات غير صالحة تُفشل الدفع

يُدخل بوب رقم بطاقة الائتمان في صفحة دفع ShopApp لكنه ينسى رقمًا. لا يحذره النظام ويُرسل مباشرة — يرفض البنك الدفع. يُعيد الإدخال مع مسافة زائدة، فيفشل مجددًا. كوبون الخصم أسوأ — يُدخل "SAVE20" لكن الكود الفعلي "save20"؛ اختلاف حالة الأحرف يُسبب خطأ. بعد كل فشل في الدفع، يجب على بوب إعادة ملء جميع الحقول.

(2) حل التحقق من النماذج

آلية Form + validator في Flutter تعترض جميع البيانات غير الصالحة قبل الإرسال، وTextInputFormatter ينسّق الإدخال فوريًا، وonSaved يجمع كل شيء بشكل موحد.

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

// Validate entire form before submission
if (_formKey.currentState!.validate()) {
  _formKey.currentState!.save();
  // All fields valid, proceed to payment
  processPayment();
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

(3) الفائدة: صفر عمليات إرسال غير صالحة

بعد التحول للتحقق بالنماذج، يحصل بوب على فحص فوري لتنسيق بطاقة الائتمان، وتحويل تلقائي لأكواد الخصم للأحرف الكبيرة، وترتفع نسبة نجاح الدفع من 70% إلى 98%.


3. نظام النماذج

(1) مكوّنات النموذج الأساسية

الويدجت المسؤولية الخاصية المفتاحية
Form حاوية النموذج، يدير FormFields الفرعية key: GlobalKey<FormState>
FormField<T> حقل مجرد، يدير التحقق/الحفظ validator, onSaved
TextFormField حقل إدخال نصي FormField controller, decoration, inputFormatters

▶ مثال

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

: بنية النموذج الأساسية

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

class CheckoutForm extends StatefulWidget {
  const CheckoutForm({super.key});

  @override
  State<CheckoutForm> createState() => _CheckoutFormState();
}

class _CheckoutFormState extends State<CheckoutForm> {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: const InputDecoration(labelText: 'Full Name'),
            validator: (value) {
              if (value == null || value.isEmpty) return 'Name is required';
              if (value.length < 2) return 'Name too short';
              return null;
            },
            onSaved: (value) => _name = value!,
          ),
          TextFormField(
            decoration: const InputDecoration(labelText: 'Email'),
            validator: (value) {
              if (value == null || !value.contains('@')) return 'Invalid email';
              return null;
            },
          ),
          ElevatedButton(
            onPressed: _submit,
            child: const Text('Submit Order'),
          ),
        ],
      ),
    );
  }

  void _submit() {
    if (_formKey.currentState!.validate()) {
      _formKey.currentState!.save();
      // Process order
    }
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

4. آلية التحقق

(1) AutovalidateMode

الوضع توقيت التشغيل حالة الاستخدام
disabled فقط عند استدعاء validate() يدويًا الافتراضي، التحقق عند الإرسال
always كل تغيير في الإدخال تغذية راجعة فورية
onUserInteraction بعد أول تفاعل للمستخدم توازن بين تجربة المستخدم والمقاطعة

▶ مثال

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

: التحقق من رقم بطاقة الائتمان

DART
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

TextFormField(
  decoration: const InputDecoration(
    labelText: 'Card Number',
    hintText: '1234 5678 9012 3456',
    prefixIcon: Icon(Icons.credit_card),
  ),
  keyboardType: TextInputType.number,
  inputFormatters: [
    FilteringTextInputFormatter.digitsOnly,
    CardNumberFormatter(),
  ],
  validator: (value) {
    if (value == null || value.isEmpty) return 'Card number is required';
    final digits = value.replaceAll(' ', '');
    if (digits.length != 16) return 'Card number must be 16 digits';
    if (!_luhnCheck(digits)) return 'Invalid card number';
    return null;
  },
  onSaved: (value) => _cardNumber = value!.replaceAll(' ', ''),
)

// Luhn algorithm for card validation
bool _luhnCheck(String digits) {
  int sum = 0;
  for (int i = 0; i < digits.length; i++) {
    int d = int.parse(digits[digits.length - 1 - i]);
    if (i % 2 == 1) {
      d *= 2;
      if (d > 9) d -= 9;
    }
    sum += d;
  }
  return sum % 10 == 0;
}
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';

// Simplified class definition
class CouponService {
  static Future<bool> validate(String code) async => code == 'SAVE20';
}

TextFormField(
  decoration: const InputDecoration(labelText: 'Coupon Code'),
  autovalidateMode: AutovalidateMode.onUserInteraction,
  validator: (value) async {
    if (value == null || value.isEmpty) return null;
    if (value.length < 4) return 'Code too short';
    final isValid = await CouponService.validate(value.toUpperCase());
    if (!isValid) return 'Invalid coupon code';
    return null;
  },
)
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

5. التحكم بتنسيق TextInputFormatter

(1) المنسّقات الشائعة

المنسّق الغرض مثال الإدخال → الإخراج
FilteringTextInputFormatter.digitsOnly أرقام فقط "abc123" → "123"
FilteringTextInputFormatter.allow(RegExp) السماح بالنمط المطابق regex مخصص
FilteringTextInputFormatter.deny(RegExp) رفض النمط المطابق regex مخصص
منسّق مخصص أي تنسيق بطاقة ائتمان/عملة/هاتف

▶ مثال

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

: منسّق رقم بطاقة الائتمان

DART
import 'package:flutter/services.dart';

class CardNumberFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    final digits = newValue.text.replaceAll(' ', '');
    final buffer = StringBuffer();
    for (int i = 0; i < digits.length && i < 16; i++) {
      if (i > 0 && i % 4 == 0) buffer.write(' ');
      buffer.write(digits[i]);
    }
    final formatted = buffer.toString();
    return TextEditingValue(
      text: formatted,
      selection: TextSelection.collapsed(offset: formatted.length),
    );
  }
}
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/services.dart';

class CurrencyFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(
    TextEditingValue oldValue,
    TextEditingValue newValue,
  ) {
    // Only allow digits and one decimal point
    final text = newValue.text.replaceAll(RegExp(r'[^\d.]'), '');
    final parts = text.split('.');
    if (parts.length > 2) return oldValue;
    if (parts.length == 2 && parts[1].length > 2) return oldValue;
    return TextEditingValue(
      text: text,
      selection: TextSelection.collapsed(offset: text.length),
    );
  }
}
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` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

: حفظ وإعادة تعيين النموذج

DART
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

// CardNumberFormatter class definition see above

class _CheckoutFormState extends State<CheckoutForm> {
  final _formKey = GlobalKey<FormState>();
  String _name = '';
  String _address = '';
  String _cardNumber = '';

  void _submit() {
    final form = _formKey.currentState!;
    if (form.validate()) {
      form.save(); // Triggers all onSaved callbacks
      processOrder(name: _name, address: _address, card: _cardNumber);
    }
  }

  void _reset() {
    _formKey.currentState!.reset(); // Clears all fields and errors
  }

  @override
  Widget build(BuildContext context) {
    return Form(
      key: _formKey,
      child: Column(children: [
        TextFormField(
          decoration: const InputDecoration(labelText: 'Full Name'),
          onSaved: (v) => _name = v ?? '',
        ),
        TextFormField(
          decoration: const InputDecoration(labelText: 'Shipping Address'),
          onSaved: (v) => _address = v ?? '',
        ),
        TextFormField(
          decoration: const InputDecoration(labelText: 'Card Number'),
          inputFormatters: [FilteringTextInputFormatter.digitsOnly, CardNumberFormatter()],
          onSaved: (v) => _cardNumber = v?.replaceAll(' ', '') ?? '',
        ),
        const SizedBox(height: 20),
        Row(children: [
          Expanded(child: OutlinedButton(onPressed: _reset, child: const Text('Reset'))),
          const SizedBox(width: 12),
          Expanded(child: FilledButton(onPressed: _submit, child: const Text('Pay Now'))),
        ]),
      ]),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

7. مثال كامل: صفحة الدفع في ShopApp

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

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

// CardNumberFormatter class definition see above

// Simplified class definition
class ExpiryDateFormatter extends TextInputFormatter {
  @override
  TextEditingValue formatEditUpdate(TextEditingValue oldValue, TextEditingValue newValue) {
    final text = newValue.text.replaceAll(RegExp(r'[^\d]'), '');
    final buffer = StringBuffer();
    for (int i = 0; i < text.length && i < 4; i++) {
      if (i == 2) buffer.write('/');
      buffer.write(text[i]);
    }
    final formatted = buffer.toString();
    return TextEditingValue(text: formatted, selection: TextSelection.collapsed(offset: formatted.length));
  }
}

class CouponService {
  static Future<double> validateAndGetDiscount(String code) async {
    await Future.delayed(const Duration(milliseconds: 500));
    if (code == 'SAVE20') return 0.2;
    return 0;
  }
}

class CheckoutPage extends StatefulWidget {
  final double total;
  const CheckoutPage({super.key, required this.total});

  @override
  State<CheckoutPage> createState() => _CheckoutPageState();
}

class _CheckoutPageState extends State<CheckoutPage> {
  final _formKey = GlobalKey<FormState>();
  final _couponController = TextEditingController();
  String? _couponError;
  double _discount = 0;

  void _applyCoupon() async {
    final code = _couponController.text.toUpperCase();
    if (code.length < 4) {
      setState(() => _couponError = 'Code too short');
      return;
    }
    final discount = await CouponService.validateAndGetDiscount(code);
    if (discount > 0) {
      setState(() { _discount = discount; _couponError = null; });
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('${(discount * 100).toStringAsFixed(0)}% discount applied!')),
      );
    } else {
      setState(() => _couponError = 'Invalid coupon code');
    }
  }

  void _submitOrder() {
    if (!_formKey.currentState!.validate()) return;
    _formKey.currentState!.save();
    showDialog(context: context, builder: (_) => AlertDialog(
      title: const Text('Order Confirmed'),
      content: Text('Total: \$${(widget.total * (1 - _discount)).toStringAsFixed(2)}'),
      actions: [FilledButton(onPressed: () => context.go('/'), child: const Text('OK'))],
    ));
  }

  @override
  Widget build(BuildContext context) {
    final finalTotal = widget.total * (1 - _discount);
    return Scaffold(
      appBar: AppBar(title: const Text('Checkout')),
      body: SingleChildScrollView(
        padding: const EdgeInsets.all(16),
        child: Form(key: _formKey, child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            const Text('Shipping Address', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Full Name', prefixIcon: Icon(Icons.person)),
              validator: (v) => v?.isEmpty ?? true ? 'Required' : null, onSaved: (v) {}),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Address Line 1', prefixIcon: Icon(Icons.home)),
              validator: (v) => v?.isEmpty ?? true ? 'Required' : null, onSaved: (v) {}),
            const SizedBox(height: 12),
            Row(children: [
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'City'),
                validator: (v) => v?.isEmpty ?? true ? 'Required' : null)),
              const SizedBox(width: 12),
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'Zip Code'),
                keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly])),
            ]),
            const Divider(height: 32),
            const Text('Payment', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
            const SizedBox(height: 12),
            TextFormField(decoration: const InputDecoration(labelText: 'Card Number', prefixIcon: Icon(Icons.credit_card)),
              keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly, CardNumberFormatter()],
              validator: (v) => (v?.replaceAll(' ', '').length ?? 0) != 16 ? 'Invalid card number' : null),
            const SizedBox(height: 12),
            Row(children: [
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'MM/YY'),
                keyboardType: TextInputType.number, inputFormatters: [ExpiryDateFormatter()],
                validator: (v) => v?.length != 5 ? 'Invalid' : null)),
              const SizedBox(width: 12),
              Expanded(child: TextFormField(decoration: const InputDecoration(labelText: 'CVV'),
                keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(3)],
                obscureText: true, validator: (v) => v?.length != 3 ? 'Invalid' : null)),
            ]),
            const Divider(height: 32),
            TextFormField(controller: _couponController, decoration: InputDecoration(
              labelText: 'Coupon Code', errorText: _couponError,
              suffixIcon: TextButton(onPressed: _applyCoupon, child: const Text('Apply'))),
            ),
            const SizedBox(height: 24),
            Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.grey[100], borderRadius: BorderRadius.circular(12)),
              child: Column(children: [
                Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  const Text('Subtotal'), Text('\$${widget.total.toStringAsFixed(2)}')]),
                if (_discount > 0) Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  Text('Discount (${(_discount * 100).toStringAsFixed(0)}%)'),
                  Text('-\$${(widget.total * _discount).toStringAsFixed(2)}', style: const TextStyle(color: Colors.green))]),
                const Divider(),
                Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
                  const Text('Total', style: TextStyle(fontWeight: FontWeight.bold)),
                  Text('\$${finalTotal.toStringAsFixed(2)}', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold))]),
              ])),
            const SizedBox(height: 16),
            SizedBox(width: double.infinity, child: FilledButton.icon(
              onPressed: _submitOrder, icon: const Icon(Icons.lock), label: const Text('Pay \$${finalTotal.toStringAsFixed(2)}'),
              style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 16)))),
          ],
        )),
      ),
    );
  }
}
TEXT
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.

❓ أسئلة شائعة

س لماذا نستخدم GlobalKey<FormState> كمفتاح عام؟
ج طرق validate/save/reset في FormState تحتاج للاستدعاء من خارج الويدجت. GlobalKey يسمح بالوصول لحالة النموذج عبر الويدجتات.
س كيف نستخدم المُحقِّقات غير المتزامنة؟
ج يُرجع المُحقِّق Future<String?>، مدعوم أصليًا في Flutter 3.x. أثناء التحقق غير المتزامن، يعرض الحقل مؤشر تحميل.
س متى يعمل formatEditUpdate في TextInputFormatter؟
ج يعمل عند كل إدخال حرف، قبل تشغيل المُحقِّق. مثالي للتنسيق الفوري (مثل إضافة مسافات لأرقام البطاقات).
س كيف نحفظ بيانات النموذج (تصمد دوران الشاشة)؟
ج استخدم TextEditingController للاحتفاظ بالبيانات، واحفظ في SharedPreferences عند dispose. أو استخدم Riverpod لإدارة حالة النموذج.
س ما الفرق بين إرجاع null وسلسلة فارغة من المُحقِّق؟
ج null يعني التحقق نجح؛ أي سلسلة غير null تُعرض كخطأ. إرجاع سلسلة فارغة يُظهر منطقة خطأ فارغة.
س كيف نتحقق من حقول تعتمد على بعضها؟
ج اقرأ قيم الحقول الأخرى عبر setState أو controller داخل المُحقِّق. مثلًا، حقل تأكيد كلمة المرور يقرأ قيمة حقل كلمة المرور للمقارنة.

📖 ملخص


📝 تمارين

  1. أساسي (⭐): أنشئ نموذج تسجيل دخول بحقل البريد الإلكتروني وكلمة المرور — تحقق من تنسيق البريد وطول كلمة المرور ≥ 8.
  2. متوسط (⭐⭐): نفّذ إدخال رقم بطاقة الائتمان: إضافة مسافات تلقائية كل 4 أرقام، تحقق بخوارزمية Luhn الفوري، تنسيق تاريخ MM/YY.
  3. متقدم (⭐⭐⭐): نفّذ نموذج دفع كامل: عنوان الشحن + بطاقة ائتمان + تحقق غير متزامن من كوبون الخصم + حساب تلقائي للإجمالي بعد الخصم.

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

Web-Tutorial.com

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

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

100%