Formها و Validation
فرمها قلب اپلیکیشنهای تعاملی هستند. ثبتنام، login، ایجاد محصول، پرداخت — همه با فرم انجام میشوند. در این فصل با Form، TextFormField، validation و reactive forms کار میکنیم.
۹.۱ مقدمه
فرمها قلب اپلیکیشنهای تعاملی هستند. ثبتنام، login، ایجاد محصول، پرداخت — همه با فرم انجام میشوند. در این فصل با Form، TextFormField، validation و reactive forms کار میکنیم.
۹.۲ فرم پایه با Form و GlobalKey
class RegisterForm extends StatefulWidget {
const RegisterForm({super.key});
@override
State createState() => _RegisterFormState();
}
class _RegisterFormState extends State {
final _formKey = GlobalKey();
final _nameController = TextEditingController();
final _emailController = TextEditingController();
final _passwordController = TextEditingController();
bool _obscurePassword = true;
bool _isLoading = false;
@override
void dispose() {
_nameController.dispose();
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
Future _submit() async {
if (!_formKey.currentState!.validate()) return;
setState(() => _isLoading = true);
try {
await AuthService.register(
name: _nameController.text,
email: _emailController.text,
password: _passwordController.text,
);
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text("ثبتنام موفق")),
);
}
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text("خطا: $e")),
);
}
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
@override
Widget build(BuildContext context) {
return Form(
key: _formKey,
child: Column(
children: [
// Name
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: "نام و نام خانوادگی",
prefixIcon: Icon(Icons.person),
border: OutlineInputBorder(),
),
textInputAction: TextInputAction.next,
validator: (value) {
if (value == null || value.isEmpty) return "نام لازم است";
if (value.length < 3) return "نام باید حداقل ۳ حرف باشد";
return null;
},
),
const SizedBox(height: 16),
// Email
TextFormField(
controller: _emailController,
keyboardType: TextInputType.emailAddress,
textInputAction: TextInputAction.next,
decoration: const InputDecoration(
labelText: "ایمیل",
prefixIcon: Icon(Icons.email),
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) return "ایمیل لازم است";
final emailRegex = RegExp(r"^[w-.]+@([w-]+.)+[w-]{2,4}$");
if (!emailRegex.hasMatch(value)) return "ایمیل نامعتبر";
return null;
},
),
const SizedBox(height: 16),
// Password
TextFormField(
controller: _passwordController,
obscureText: _obscurePassword,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
labelText: "رمز عبور",
prefixIcon: const Icon(Icons.lock),
suffixIcon: IconButton(
icon: Icon(_obscurePassword ? Icons.visibility : Icons.visibility_off),
onPressed: () => setState(() => _obscurePassword = !_obscurePassword),
),
border: const OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) return "رمز لازم است";
if (value.length < 8) return "رمز باید حداقل ۸ حرف باشد";
if (!RegExp(r"[A-Z]").hasMatch(value)) return "حداقل یک حرف بزرگ";
if (!RegExp(r"[0-9]").hasMatch(value)) return "حداقل یک عدد";
return null;
},
onFieldSubmitted: (_) => _submit(),
),
const SizedBox(height: 24),
// Submit
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _isLoading ? null : _submit,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text("ثبتنام"),
),
),
],
),
);
}
}
۹.۳ انواع TextField
// Email
TextFormField(
keyboardType: TextInputType.emailAddress,
autofillHints: const [AutofillHints.email],
)
// Phone
TextFormField(
keyboardType: TextInputType.phone,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(11),
],
)
// Numeric
TextFormField(
keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r"[0-9.]")),
],
)
// Multi-line
TextFormField(
maxLines: 5,
minLines: 3,
keyboardType: TextInputType.multiline,
)
// Password
TextFormField(
obscureText: true,
autofillHints: const [AutofillHints.password],
)
// با counter
TextFormField(
maxLength: 100,
decoration: const InputDecoration(
counterText: "", // یا "" برای حذف counter
),
)
// با debounce برای search
class SearchField extends StatefulWidget {
@override
State createState() => _SearchFieldState();
}
class _SearchFieldState extends State {
Timer? _debounce;
void _onChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 500), () {
// search after 500ms idle
print("Search: $value");
});
}
@override
void dispose() {
_debounce?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(
onChanged: _onChanged,
decoration: const InputDecoration(prefixIcon: Icon(Icons.search)),
);
}
}
۹.۴ سایر input ها
// === Checkbox ===
bool _agreed = false;
CheckboxListTile(
value: _agreed,
onChanged: (v) => setState(() => _agreed = v ?? false),
title: const Text("شرایط را میپذیرم"),
controlAffinity: ListTileControlAffinity.leading,
)
// === Switch ===
bool _notifications = true;
SwitchListTile(
value: _notifications,
onChanged: (v) => setState(() => _notifications = v),
title: const Text("اعلانها"),
)
// === Radio ===
String? _gender;
Column(
children: [
RadioListTile(
value: "male",
groupValue: _gender,
onChanged: (v) => setState(() => _gender = v),
title: const Text("آقا"),
),
RadioListTile(
value: "female",
groupValue: _gender,
onChanged: (v) => setState(() => _gender = v),
title: const Text("خانم"),
),
],
)
// === Slider ===
double _value = 50;
Slider(
value: _value,
min: 0,
max: 100,
divisions: 10,
label: _value.round().toString(),
onChanged: (v) => setState(() => _value = v),
)
// === Dropdown ===
String? _category;
DropdownButtonFormField(
value: _category,
decoration: const InputDecoration(labelText: "دستهبندی"),
items: const [
DropdownMenuItem(value: "electronics", child: Text("الکترونیک")),
DropdownMenuItem(value: "clothing", child: Text("پوشاک")),
DropdownMenuItem(value: "books", child: Text("کتاب")),
],
onChanged: (v) => setState(() => _category = v),
validator: (v) => v == null ? "انتخاب کنید" : null,
)
// === Date Picker ===
DateTime? _selectedDate;
Future _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(1900),
lastDate: DateTime.now(),
);
if (picked != null) setState(() => _selectedDate = picked);
}
ElevatedButton.icon(
onPressed: _pickDate,
icon: const Icon(Icons.calendar_today),
label: Text(_selectedDate != null
? "${_selectedDate!.year}/${_selectedDate!.month}/${_selectedDate!.day}"
: "انتخاب تاریخ"),
)
// === Time Picker ===
TimeOfDay? _selectedTime;
final picked = await showTimePicker(
context: context,
initialTime: TimeOfDay.now(),
);
// === تاریخ شمسی - با persian_datetime_picker ===
// flutter pub add persian_datetime_picker
import "package:persian_datetime_picker/persian_datetime_picker.dart";
Jalali? _selectedJalali;
final picked = await showPersianDatePicker(
context: context,
initialDate: Jalali.now(),
firstDate: Jalali(1390),
lastDate: Jalali(1500),
);
۹.۵ Image Picker و File Picker
flutter pub add image_picker file_picker
import "package:image_picker/image_picker.dart";
import "dart:io";
// انتخاب از Gallery یا Camera
final picker = ImagePicker();
// عکس
final XFile? image = await picker.pickImage(
source: ImageSource.gallery, // یا ImageSource.camera
imageQuality: 80,
maxWidth: 1080,
);
if (image != null) {
final file = File(image.path);
// upload یا نمایش
Image.file(file)
}
// چندین عکس
final List images = await picker.pickMultiImage();
// ویدئو
final XFile? video = await picker.pickVideo(
source: ImageSource.camera,
maxDuration: const Duration(minutes: 1),
);
// === File Picker ===
import "package:file_picker/file_picker.dart";
// PDF
FilePickerResult? result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ["pdf", "doc", "docx"],
);
if (result != null) {
File file = File(result.files.single.path!);
String fileName = result.files.single.name;
int fileSize = result.files.single.size;
}
// چندین فایل
final result = await FilePicker.platform.pickFiles(
allowMultiple: true,
type: FileType.image,
);
۹.۶ Reactive Forms
برای فرمهای پیچیده، package flutter_form_builder یا reactive_forms راحتتر است.
flutter pub add flutter_form_builder form_builder_validators
import "package:flutter_form_builder/flutter_form_builder.dart";
import "package:form_builder_validators/form_builder_validators.dart";
class AdvancedForm extends StatefulWidget {
@override
State createState() => _AdvancedFormState();
}
class _AdvancedFormState extends State {
final _formKey = GlobalKey();
void _submit() {
if (_formKey.currentState!.saveAndValidate()) {
final data = _formKey.currentState!.value;
print(data);
// {name: ..., email: ..., age: 25, hobbies: [...]}
}
}
@override
Widget build(BuildContext context) {
return FormBuilder(
key: _formKey,
child: Column(
children: [
FormBuilderTextField(
name: "name",
decoration: const InputDecoration(labelText: "نام"),
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(errorText: "لازم است"),
FormBuilderValidators.minLength(3),
]),
),
FormBuilderTextField(
name: "email",
decoration: const InputDecoration(labelText: "ایمیل"),
validator: FormBuilderValidators.compose([
FormBuilderValidators.required(),
FormBuilderValidators.email(),
]),
),
FormBuilderDropdown(
name: "country",
decoration: const InputDecoration(labelText: "کشور"),
items: const [
DropdownMenuItem(value: "ir", child: Text("ایران")),
DropdownMenuItem(value: "us", child: Text("آمریکا")),
],
),
FormBuilderCheckboxGroup(
name: "hobbies",
decoration: const InputDecoration(labelText: "علایق"),
options: const [
FormBuilderFieldOption(value: "reading", child: Text("کتاب")),
FormBuilderFieldOption(value: "sports", child: Text("ورزش")),
FormBuilderFieldOption(value: "music", child: Text("موسیقی")),
],
),
FormBuilderSlider(
name: "age",
min: 18,
max: 100,
initialValue: 25,
),
FormBuilderDateTimePicker(
name: "birthday",
inputType: InputType.date,
decoration: const InputDecoration(labelText: "تاریخ تولد"),
),
ElevatedButton(onPressed: _submit, child: const Text("ارسال")),
],
),
);
}
}
۹.۷ Validation از سرور
گاهی validation در client کافی نیست — مثلاً «این ایمیل قبلاً ثبت شده». باید پاسخ از سرور را به فرم برگردانیم.
Future _submit() async {
if (!_formKey.currentState!.validate()) return;
try {
await api.register(...);
} on ValidationException catch (e) {
// e.errors = {"email": ["This email is already taken"]}
setState(() => _serverErrors = e.errors);
_formKey.currentState!.validate(); // re-validate to show server errors
}
}
// در validator:
TextFormField(
validator: (value) {
// client validation
if (value == null || value.isEmpty) return "ایمیل لازم است";
// server error
if (_serverErrors?["email"] != null) {
return _serverErrors!["email"]!.first;
}
return null;
},
onChanged: (_) {
// پاک کردن server error با تغییر
if (_serverErrors?["email"] != null) {
setState(() => _serverErrors!.remove("email"));
}
},
)
۹.۸ بهترین تجربیات فرم
- همیشه dispose کنید. Controller ها.
- autofillHints. برای تجربه بهتر.
- textInputAction. next/done.
- FocusNode برای navigation بین فیلدها.
- Loading state در submit. دکمه disable.
- Validation در blur نه onChange. annoying نباشد.
- پیام خطای واضح فارسی.
- Server errors را به فرم برگردانید.
- Persistent data. اگر کاربر pop کرد، state حفظ شود.
- Submit با Enter. از onFieldSubmitted.
۹.۹ خلاصه فصل
- Form و GlobalKey<FormState>
- TextFormField با validation کامل
- Checkbox، Switch، Radio، Slider، Dropdown
- Date و Time picker (با persian_datetime_picker)
- Image picker و File picker
- flutter_form_builder برای فرم پیچیده
- Server-side validation
در فصل بعد: انیمیشنها — Implicit، Explicit، Hero، Lottie.