Flutter: النماذج والإدخال
النماذج هي الجسر بين المستخدمين وتطبيقك — التحقق المتساهل يُدخل بيانات سيئة؛ وتجربة المستخدم السيئة تُبعد المستخدمين.
📋 المتطلبات السابقة: يجب أن تتقن ما يلي أولًا
- الدرس 8: التنقل والتوجيه
1. ما ستتعلمه
- Form / FormField
<T>/ TextFormField وGlobalKey<FormState> - التحقق: سلسلة دالة المُحقِّق، التحقق غير المتزامن، عرض الأخطاء الفوري
- التحكم في الإدخال: TextInputFormatter (تنسيق العملة، قناع الهاتف)
- حفظ واستعادة حالة النموذج: onSaved / reset / AutovalidateMode
- صفحة الدفع في ShopApp: عنوان الشحن / بطاقة الائتمان / التحقق من كوبون الخصم
2. قصة حقيقية عن نموذج دفع فاشل
(1) المشكلة: بيانات غير صالحة تُفشل الدفع
يُدخل بوب رقم بطاقة الائتمان في صفحة دفع ShopApp لكنه ينسى رقمًا. لا يحذره النظام ويُرسل مباشرة — يرفض البنك الدفع. يُعيد الإدخال مع مسافة زائدة، فيفشل مجددًا. كوبون الخصم أسوأ — يُدخل "SAVE20" لكن الكود الفعلي "save20"؛ اختلاف حالة الأحرف يُسبب خطأ. بعد كل فشل في الدفع، يجب على بوب إعادة ملء جميع الحقول.
(2) حل التحقق من النماذج
آلية Form + validator في Flutter تعترض جميع البيانات غير الصالحة قبل الإرسال، وTextInputFormatter ينسّق الإدخال فوريًا، وonSaved يجمع كل شيء بشكل موحد.
import 'package:flutter/material.dart';
// Validate entire form before submission
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
// All fields valid, proceed to payment
processPayment();
}
> الإخراج: شغّل محليًا باستخدام 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 |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: بنية النموذج الأساسية
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
}
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
4. آلية التحقق
(1) AutovalidateMode
| الوضع | توقيت التشغيل | حالة الاستخدام |
|---|---|---|
disabled |
فقط عند استدعاء validate() يدويًا | الافتراضي، التحقق عند الإرسال |
always |
كل تغيير في الإدخال | تغذية راجعة فورية |
onUserInteraction |
بعد أول تفاعل للمستخدم | توازن بين تجربة المستخدم والمقاطعة |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: التحقق من رقم بطاقة الائتمان
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;
}
> الإخراج: شغّل محليًا باستخدام 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';
// 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;
},
)
> الإخراج: شغّل محليًا باستخدام 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 مخصص |
| منسّق مخصص | أي تنسيق | بطاقة ائتمان/عملة/هاتف |
▶ مثال
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: منسّق رقم بطاقة الائتمان
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),
);
}
}
> الإخراج: شغّل محليًا باستخدام 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/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),
);
}
}
> الإخراج: شغّل محليًا باستخدام 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` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
: حفظ وإعادة تعيين النموذج
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'))),
]),
]),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
7. مثال كامل: صفحة الدفع في ShopApp
⚙️ تثبيت التبعية:
flutter pub add go_router
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)))),
],
)),
),
);
}
}
> الإخراج: شغّل محليًا باستخدام Flutter SDK (Flutter 3.x / Dart 3.x). خادم Piston لا يحتوي على Flutter؛ استخدم `flutter run` على جهازك المحلي للمقارنة. قد تختلف واجهة/حالة المستخدم الفعلية قليلاً بين المنصات.
❓ أسئلة شائعة
<FormState> كمفتاح عام؟Future<String?>، مدعوم أصليًا في Flutter 3.x. أثناء التحقق غير المتزامن، يعرض الحقل مؤشر تحميل.📖 ملخص
- Form + GlobalKey
<FormState>يدير بشكل موحد التحقق والحفظ وإعادة التعيين - يُرجع المُحقِّق null عند النجاح، أو رسالة خطأ عند الفشل
- AutovalidateMode يتحكم في توقيت تشغيل التحقق
- TextInputFormatter ينسّق الإدخال فوريًا (بطاقة ائتمان/عملة/تاريخ)
- onSaved يجمع بيانات النموذج بشكل موحد؛ reset يمسح جميع الحقول
📝 تمارين
- أساسي (⭐): أنشئ نموذج تسجيل دخول بحقل البريد الإلكتروني وكلمة المرور — تحقق من تنسيق البريد وطول كلمة المرور ≥ 8.
- متوسط (⭐⭐): نفّذ إدخال رقم بطاقة الائتمان: إضافة مسافات تلقائية كل 4 أرقام، تحقق بخوارزمية Luhn الفوري، تنسيق تاريخ MM/YY.
- متقدم (⭐⭐⭐): نفّذ نموذج دفع كامل: عنوان الشحن + بطاقة ائتمان + تحقق غير متزامن من كوبون الخصم + حساب تلقائي للإجمالي بعد الخصم.