Complete form-to-API pipeline — FormMixin, ViewController, Params, validation, LoadingButton, file upload, and success handling in Flutter_Base.
Figma Form Design
↓
Step 1: Create ViewController (controllers + toParams)
↓
Step 2: Create Params class (toJson / toFormData)
↓
Step 3: Create Submit Cubit (executeAsync + POST/PUT)
↓
Step 4: Build Form widget (CustomTextFiled + validators)
↓
Step 5: Build Screen (BlocProvider + BlocListener + FormMixin)
↓
Step 6: Connect LoadingButton (validateAndScroll + submit)
↓
Step 7: Handle Success (Go.back(result) or successDialog)
↓
Step 8: Handle Error (toast auto-handled by executeAsync)
كل الـ controllers والـ UI logic في ViewController class — ممنوع في الـ View مباشرة.
class CreateProductViewController {
final TextEditingController nameController = TextEditingController();
final TextEditingController priceController = TextEditingController();
final TextEditingController descriptionController = TextEditingController();
final ValueNotifier<CategoryEntity?> selectedCategory = ValueNotifier(null);
final ValueNotifier<File?> selectedImage = ValueNotifier(null);
ProductParams toParams() => ProductParams(
name: nameController.text.trim(),
price: double.tryParse(priceController.text) ?? 0,
description: descriptionController.text.trim(),
categoryId: selectedCategory.value?.id ?? '',
image: selectedImage.value,
);
void dispose() {
nameController.dispose();
priceController.dispose();
descriptionController.dispose();
selectedCategory.dispose();
selectedImage.dispose();
}
}
// For EDIT — pre-fill from existing entity
class EditProductViewController extends CreateProductViewController {
EditProductViewController.fromEntity(ProductEntity entity) {
nameController.text = entity.name;
priceController.text = entity.price.toString();
descriptionController.text = entity.description;
selectedCategory.value = entity.category;
}
}
class ProductParams {
final String name;
final double price;
final String description;
final String categoryId;
final File? image;
const ProductParams({
required this.name,
required this.price,
required this.description,
required this.categoryId,
this.image,
});
Map<String, dynamic> toJson() => {
'name': name,
'price': price,
'description': description,
'category_id': categoryId,
};
}
class ProductParams {
// ... same fields ...
Future<FormData> toFormData() async {
final map = <String, dynamic>{
'name': name,
'price': price,
'description': description,
'category_id': categoryId,
};
if (image != null) {
map['image'] = await MultipartFile.fromFile(
image!.path,
filename: image!.path.split('/').last,
);
}
return FormData.fromMap(map);
}
}
@injectable
class CreateProductCubit extends AsyncCubit<ProductEntity> {
CreateProductCubit() : super(ProductEntity.initial());
Future<void> createProduct(ProductParams params) async {
await executeAsync(
operation: () => baseCrudUseCase.call(CrudBaseParams(
api: ApiConstants.products,
httpRequestType: HttpRequestType.post,
body: params.toJson(),
mapper: (json) => ProductEntity.fromJson(json['data']),
)),
);
}
}
@injectable
class EditProductCubit extends AsyncCubit<ProductEntity> {
EditProductCubit() : super(ProductEntity.initial());
Future<void> editProduct(String id, ProductParams params) async {
await executeAsync(
operation: () => baseCrudUseCase.call(CrudBaseParams(
api: ApiConstants.productDetail(id),
httpRequestType: HttpRequestType.put,
body: params.toJson(),
mapper: (json) => ProductEntity.fromJson(json['data']),
)),
);
}
}
@injectable
class ContactUsCubit extends AsyncCubit<BaseModel?> {
ContactUsCubit() : super(null);
Future<void> contactUs(ContactUsParams params) async {
await executeAsync(
operation: () => baseCrudUseCase.call(CrudBaseParams(
api: ApiConstants.contactUs,
httpRequestType: HttpRequestType.post,
body: params.toJson(),
mapper: (json) => BaseModel.fromJson(json),
)),
successEmitter: (success) {
Go.back();
successDialog(context: Go.context, title: LocaleKeys.messageSentSuccessfully.tr());
},
);
}
}
class _ProductFormWidget extends StatelessWidget {
const _ProductFormWidget({required this.vc, required this.formKey});
final CreateProductViewController vc;
final GlobalKey<FormState> formKey;
@override
Widget build(BuildContext context) {
return Form(
key: formKey,
child: Column(
children: [
CustomTextFiled(
title: LocaleKeys.productName.tr(),
hint: LocaleKeys.enterProductName.tr(),
controller: vc.nameController,
validator: (v) => v?.isEmpty == true ? LocaleKeys.required.tr() : null,
),
12.szH,
CustomTextFiled(
title: LocaleKeys.price.tr(),
controller: vc.priceController,
keyboardType: TextInputType.number,
validator: (v) {
if (v?.isEmpty == true) return LocaleKeys.required.tr();
if (double.tryParse(v!) == null) return LocaleKeys.invalidNumber.tr();
return null;
},
),
12.szH,
CustomTextFiled(
title: LocaleKeys.description.tr(),
controller: vc.descriptionController,
maxLines: 4,
),
12.szH,
_CategoryDropdown(
selectedCategory: vc.selectedCategory,
onChanged: (cat) => vc.selectedCategory.value = cat,
),
12.szH,
ValueListenableBuilder<File?>(
valueListenable: vc.selectedImage,
builder: (_, file, __) => _ImagePickerWidget(
file: file,
onPicked: (f) => vc.selectedImage.value = f,
),
),
],
),
);
}
}
class _CreateProductBodyState extends State<_CreateProductBody> with FormMixin {
late final CreateProductViewController _vc = CreateProductViewController();
@override
void dispose() {
_vc.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocListener<CreateProductCubit, AsyncState<ProductEntity>>(
listener: (context, state) {
if (state.isSuccess && state.data != null) {
Go.back(state.data);
}
},
child: SingleChildScrollView(
child: Column(
children: [
_ProductFormWidget(vc: _vc, formKey: formKey),
24.szH,
LoadingButton(
title: LocaleKeys.create.tr(),
cubit: context.read<CreateProductCubit>(),
onTap: () {
if (!params.validateAndScroll()) return;
context.read<CreateProductCubit>().createProduct(_vc.toParams());
},
),
],
).paddingAll(AppPadding.p16),
),
);
}
}
LoadingButton(
title: LocaleKeys.submit.tr(),
cubit: context.read<SubmitCubit>(),
onTap: () {
if (!params.validateAndScroll()) return;
final params = _vc.toParams();
context.read<SubmitCubit>().submit(params);
},
)
// Required field