Flutter 模型验证
- 作者仓库星标 0
- 作者仓库 skills-registry
Forms with Bloc and Formz
Pushes per-field validation into typed value objects (FormzInput<TValue, TError>) so the Bloc only orchestrates: hold the fields, derive overall validity, submit, and route server-side validation errors back to the right field. Form fields become reusable types (EmailInput, PhoneInput) shared across screens. Submission status (FormzSubmissionStatus.inProgress / success / failure) is decoupled from field validity so a valid form can still fail to submit, and vice versa. Builds on flutter-bloc-async-api for the underlying request/response flow.
Contents
- Why formz
- FormzInput per field
- Form Bloc state shape
- Mapping Laravel 422 errors to fields
- Workflow: Build a form with Bloc and formz
- Applied to Talabat-clone
- Examples
Why formz
A naive form Bloc holds raw String fields and runs validation in the submit handler. That has three failure modes: (1) validation rules duplicate across forms that share a field (email validator written three times); (2) the View has to know whether to display an error (don't show "required" before the user touches the field); (3) server-side and client-side errors are tracked separately and drift apart.
formz solves all three by making each field a value object:
- A
FormzInput<TValue, TError>knows its current value, its validity, and whether it has been touched. displayErrorreturns the error only if the field isdirty— the View can naively render it without an "if pure return null" check.- Validation rules live on the input class, not the Bloc. Reuse
EmailInputin login, sign-up, profile-edit, and forgot-password.
FormzInput per field
A field is a sealed enum of failure modes plus a FormzInput subclass.
import 'package:formz/formz.dart';
enum EmailValidationError { empty, malformed }
class EmailInput extends FormzInput<String, EmailValidationError> {
const EmailInput.pure() : super.pure('');
const EmailInput.dirty([super.value = '']) : super.dirty();
static final _emailRegex = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
@override
EmailValidationError? validator(String value) {
if (value.isEmpty) return EmailValidationError.empty;
if (!_emailRegex.hasMatch(value)) return EmailValidationError.malformed;
return null;
}
}
.pure(value) is for the initial state — field is untouched, no error displayed.
.dirty(value) is for every subsequent update from user input — the validator runs and displayError will surface its result.
For one-off validators (a field used only in one form), inline the class in the same file as the Bloc. For reusable validators (EmailInput, PhoneInput, PasswordInput), put them in lib/core/forms/ so every form Bloc can import them.
Form Bloc state shape
The state holds all fields as inputs, an overall submission status, and a slot for server-level failures that aren't per-field.
@freezed
class LoginFormState with _$LoginFormState {
const factory LoginFormState({
@Default(EmailInput.pure()) EmailInput email,
@Default(PasswordInput.pure()) PasswordInput password,
@Default(FormzSubmissionStatus.initial) FormzSubmissionStatus status,
String? serverError, // for 401, 500, network — never for 422
}) = _LoginFormState;
const LoginFormState._();
bool get isValid => Formz.validate([email, password]);
}
A getter isValid derives from Formz.validate([...]) rather than being stored. The View consults state.isValid to decide whether the submit button is enabled — no duplicated truth to keep in sync.
The initial state with all-pure() inputs is not valid — pure inputs whose validator rejects empty strings are invalid-but-not-displayed. Submit is correctly disabled at start; displayError returns null until the field is dirty, so the user doesn't see "Email required" before typing.
Submission status is independent of validity. A form can be FormzSubmissionStatus.inProgress while isValid is true. After a 422, status flips to failure but isValid may still be true on the client (the server disagreed). Treat them as orthogonal axes.
formz exposes a FormzSubmissionStatusX extension with convenience getters: status.isInitial, status.isInProgress, status.isSuccess, status.isFailure, status.isCanceled, and the load-bearing status.isInProgressOrSuccess for "keep the submit button disabled while in flight and after success until navigation completes". Use them instead of raw status == FormzSubmissionStatus.inProgress comparisons.
Mapping Laravel 422 errors to fields
When flutter-bloc-async-api's repository returns Result.failure(Failure.validation(errors)) — the Failure taxonomy defined in flutter-bloc-async-api — the form Bloc splatters those errors back onto the corresponding inputs.
Laravel response:
{
"message": "The given data was invalid.",
"errors": {
"email": ["The email has already been taken."],
"phone": ["The phone must be at least 10 digits."]
}
}
Each FormzInput subclass declares an extra final String? serverError field initialized by both .pure() and .dirty() constructors. The View prefers serverError over the client-side displayError. There is no extension, no withServerError(...) method, no freezed wrapper — just a constructor parameter.
The mapping in the Bloc's submission handler:
Future<void> _onSubmitted(
LoginFormSubmitted event,
Emitter<LoginFormState> emit,
) async {
if (!state.isValid) return;
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
final result = await _repo.login(
email: state.email.value,
password: [REDACTED:credential],
);
emit(switch (result) {
ResultSuccess() =>
state.copyWith(status: FormzSubmissionStatus.success),
ResultFailure(failure: ValidationFailure(:final errors)) =>
state.copyWith(
status: FormzSubmissionStatus.failure,
email: errors.containsKey('email')
? EmailInput.dirty(state.email.value, errors['email']!.first)
: state.email,
password: errors.containsKey('password')
? PasswordInput.dirty(state.password.value, errors['password']!.first)
: state.password,
),
ResultFailure(:final failure) =>
state.copyWith(
status: FormzSubmissionStatus.failure,
serverError: _messageFor(failure), // canonical mapper lives in flutter-bloc-async-api
),
});
}
Stale-server-error invariant. The _onEmailChanged keystroke handler emits EmailInput.dirty(e.value) with no second argument, so serverError defaults to null and stale 422 messages clear automatically as the user types. The form re-renders without the obsolete error before the next submit even fires.
Workflow: Build a form with Bloc and formz
Task Progress
- Step 1 — Add the dep.
flutter pub add formz. - Step 2 — Decide which inputs are reusable. Inputs used in 2+ forms go in
lib/core/forms/. One-offs stay in the feature folder. - Step 3 — Write the
FormzInputsubclasses. Each has apure()ctor,dirty()ctor, validation enum, andvalidator()override. - Step 4 — Define the form state.
@freezedclass with one input per field,FormzSubmissionStatus, optionalserverError, and a derivedisValidgetter usingFormz.validate([...]). - Step 5 — Define the events. One
<Field>Changed(value)per field, plusSubmittedand (optional)Reset. - Step 6 — Implement field handlers. Each handler emits
state.copyWith(<field>: <Input>.dirty(newValue)). Usetransformer: sequential()for keystroke-driven fields (avoidconcurrent()reordering). - Step 7 — Implement the submit handler. Use
transformer: droppable()so double-taps on submit are ignored. Guard withif (!state.isValid) return;. EmitinProgress, call the repository, pattern-match theResult. - Step 8 — Map 422 to fields. In the failure branch, distribute
ValidationFailure.errorsmap keys onto the matching inputs via<Input>.dirty(value)+ a server-error attachment. - Step 9 — Wire the View.
TextFieldper input —decoration.errorTextreads fromstate.<field>.displayError(mapped through a_message()helper). Submit button usesstate.isValid && state.status != FormzSubmissionStatus.inProgress. - Step 10 — Feedback loop. Run on device → submit with invalid data → assert per-field errors render → submit with data that triggers a server 422 → assert those errors render on the right fields. If a 422 error message shows in a snackbar instead of next to the field, the Bloc swallowed
ValidationFailureintoserverErrorinstead of splatting it.
Applied to Talabat-clone
Two forms from the PRDs map directly.
AddressFormBloc (prd.md §2.1 selection, §27 fields)
Address selection (saved-addresses list, "use my current location", far-address warning) is prd.md §2.1. Address entry fields are prd.md §27: apartment number (رقم الشقة), floor (الطابق), landmark (علامة مميزة), plus special instructions (تعليمات خاصة بالعنوان). The form Bloc holds an ApartmentInput, FloorInput, LandmarkInput, SpecialInstructionsInput, and a CityInput populated automatically from a reverse-geocode lookup (the city field is shown read-only and is "dirty" on initial load — pure inputs whose validator rejects empty strings would block submit).
Submitting POSTs to /api/users/me/addresses. The Laravel side validates that apartment is non-empty and city_id exists in the cities table. A 422 with {"apartment": ["The apartment field is required."]} maps to state.copyWith(apartment: ApartmentInput.dirty(state.apartment.value, errors['apartment']!.first)) — the user sees the error inline.
OtpVerifyBloc (PRD_states.md §14)
The user lifecycle is pending_verification → active via phone/email OTP. The form has a single OtpCodeInput with a fixed-length validator (6 digits) and a submit. Server-side errors include 422 ({"code": ["Invalid code"]}); expired codes come back as Failure.unknown or a custom code your repository maps to Failure.unknown(...), surfaced as state.serverError rather than a per-field error. A Resend event triggers a separate repository call gated by a cooldown timer — Stream.periodic(Duration(seconds: 1)) consumed via a Ticked event handler is the lightest implementation; no real-time transport, so flutter-bloc-stream-tracking does not apply.
Examples
A LoginFormBloc complete with field inputs, state, events, handler, and view. The form has email and password; the API is POST /api/login returning either a 200 with a token or a 422 with field errors.
lib/core/forms/email_input.dart
import 'package:formz/formz.dart';
enum EmailValidationError { empty, malformed }
class EmailInput extends FormzInput<String, EmailValidationError> {
// `pure` accepts an optional seed value so an edit-profile screen can
// pre-populate the field without making it dirty. `serverError` is always
// null for pure inputs (you only get a server error after submitting).
// Dart requires every final field to be set by every constructor.
const EmailInput.pure([String value = ''])
: serverError = null,
super.pure(value);
const EmailInput.dirty([super.value = '', this.serverError]) : super.dirty();
final String? serverError;
static final _re = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
@override
EmailValidationError? validator(String value) {
if (value.isEmpty) return EmailValidationError.empty;
if (!_re.hasMatch(value)) return EmailValidationError.malformed;
return null;
}
// FormzInput's default `==` covers `isPure` and `value` only — we add
// `serverError` so a `BlocSelector<EmailInput>` rebuilds when only the
// server message changes (same value, same purity, new error string).
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is EmailInput &&
other.value == value &&
other.isPure == isPure &&
other.serverError == serverError);
@override
int get hashCode => Object.hash(value, isPure, serverError);
}
lib/core/forms/password_input.dart
import 'package:formz/formz.dart';
enum PasswordValidationError { empty, tooShort }
class PasswordInput extends FormzInput<String, PasswordValidationError> {
const PasswordInput.pure([String value = ''])
: serverError = null,
super.pure(value);
const PasswordInput.dirty([super.value = '', this.serverError]) : super.dirty();
final String? serverError;
@override
PasswordValidationError? validator(String value) {
if (value.isEmpty) return PasswordValidationError.empty;
if (value.length < 8) return PasswordValidationError.tooShort;
return null;
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is PasswordInput &&
other.value == value &&
other.isPure == isPure &&
other.serverError == serverError);
@override
int get hashCode => Object.hash(value, isPure, serverError);
}
lib/ui/features/login/bloc/login_form_state.dart
import 'package:formz/formz.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../../../core/forms/email_input.dart';
import '../../../../core/forms/password_input.dart';
part 'login_form_state.freezed.dart';
@freezed
class LoginFormState with _$LoginFormState {
const factory LoginFormState({
@Default(EmailInput.pure()) EmailInput email,
@Default(PasswordInput.pure()) PasswordInput password,
@Default(FormzSubmissionStatus.initial) FormzSubmissionStatus status,
String? serverError,
}) = _LoginFormState;
const LoginFormState._();
bool get isValid => Formz.validate([email, password]);
}
lib/ui/features/login/bloc/login_form_bloc.dart
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import '../../../../core/forms/email_input.dart';
import '../../../../core/forms/password_input.dart';
import '../../../../core/result/failure.dart';
import '../../../../core/result/result.dart';
import '../../../../data/repositories/auth_repository.dart';
import 'login_form_event.dart';
import 'login_form_state.dart';
class LoginFormBloc extends Bloc<LoginFormEvent, LoginFormState> {
LoginFormBloc({required AuthRepository repo})
: _repo = repo,
super(const LoginFormState()) {
on<LoginEmailChanged>(_onEmailChanged, transformer: sequential());
on<LoginPasswordChanged>(_onPasswordChanged, transformer: sequential());
on<LoginSubmitted>(_onSubmitted, transformer: droppable());
}
final AuthRepository _repo;
void _onEmailChanged(LoginEmailChanged e, Emitter<LoginFormState> emit) {
emit(state.copyWith(email: EmailInput.dirty(e.value)));
}
void _onPasswordChanged(LoginPasswordChanged e, Emitter<LoginFormState> emit) {
emit(state.copyWith(password: [REDACTED:credential](e.value)));
}
Future<void> _onSubmitted(
LoginSubmitted event,
Emitter<LoginFormState> emit,
) async {
if (!state.isValid) return;
emit(state.copyWith(status: FormzSubmissionStatus.inProgress));
final result = await _repo.login(
email: state.email.value,
password: [REDACTED:credential],
);
emit(switch (result) {
ResultSuccess() => state.copyWith(status: FormzSubmissionStatus.success),
ResultFailure(failure: ValidationFailure(:final errors)) =>
state.copyWith(
status: FormzSubmissionStatus.failure,
email: errors.containsKey('email')
? EmailInput.dirty(state.email.value, errors['email']!.first)
: state.email,
password: errors.containsKey('password')
? PasswordInput.dirty(state.password.value, errors['password']!.first)
: state.password,
),
ResultFailure(failure: UnauthorizedFailure()) =>
state.copyWith(
status: FormzSubmissionStatus.failure,
serverError: 'Email or password is incorrect',
),
ResultFailure(:final failure) =>
state.copyWith(
status: FormzSubmissionStatus.failure,
serverError: _messageFor(failure),
),
});
}
String _messageFor(Failure f) => switch (f) {
NetworkFailure() => 'No internet connection',
TimeoutFailure() => 'Server is taking too long. Try again.',
ServerFailure(:final statusCode) => 'Server error ($statusCode)',
_ => 'Something went wrong',
};
}
lib/ui/features/login/view/login_view.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:formz/formz.dart';
import '../../../../core/forms/email_input.dart';
import '../../../../core/forms/password_input.dart';
import '../bloc/login_form_bloc.dart';
import '../bloc/login_form_event.dart';
import '../bloc/login_form_state.dart';
class LoginView extends StatelessWidget {
const LoginView({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (ctx) => LoginFormBloc(repo: ctx.read()),
child: const _LoginScaffold(),
);
}
}
class _LoginScaffold extends StatelessWidget {
const _LoginScaffold();
@override
Widget build(BuildContext context) {
return BlocListener<LoginFormBloc, LoginFormState>(
listenWhen: (p, n) => p.status != n.status,
listener: (ctx, state) {
if (state.status == FormzSubmissionStatus.success) {
Navigator.of(ctx).pushReplacementNamed('/home');
} else if (state.serverError != null) {
ScaffoldMessenger.of(ctx).showSnackBar(
SnackBar(content: Text(state.serverError!)),
);
}
},
child: Scaffold(
appBar: AppBar(title: const Text('Sign in')),
body: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
BlocSelector<LoginFormBloc, LoginFormState, EmailInput>(
selector: (s) => s.email,
builder: (ctx, email) => TextField(
decoration: InputDecoration(
labelText: 'Email',
errorText: email.serverError ?? _emailMsg(email.displayError),
),
onChanged: (v) => ctx.read<LoginFormBloc>().add(LoginEmailChanged(v)),
),
),
const SizedBox(height: 12),
BlocSelector<LoginFormBloc, LoginFormState, PasswordInput>(
selector: (s) => s.password,
builder: (ctx, password) => TextField(
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
errorText: password.serverError ?? _passwordMsg(password.displayError),
),
onChanged: (v) => ctx.read<LoginFormBloc>().add(LoginPasswordChanged(v)),
),
),
const SizedBox(height: 24),
BlocBuilder<LoginFormBloc, LoginFormState>(
builder: (ctx, state) => FilledButton(
onPressed: state.isValid && state.status != FormzSubmissionStatus.inProgress
? () => ctx.read<LoginFormBloc>().add(const LoginSubmitted())
: null,
child: state.status == FormzSubmissionStatus.inProgress
? const CircularProgressIndicator()
: const Text('Sign in'),
),
),
],
),
),
),
);
}
String? _emailMsg(EmailValidationError? e) => switch (e) {
EmailValidationError.empty => 'Email is required',
EmailValidationError.malformed => 'Enter a valid email',
null => null,
};
String? _passwordMsg(PasswordValidationError? e) => switch (e) {
PasswordValidationError.empty => 'Password is required',
PasswordValidationError.tooShort => 'At least 8 characters',
null => null,
};
}
errorText: email.serverError ?? _emailMsg(email.displayError) is the whole 422 round-trip in one line: server error wins when present, otherwise client-side validator output (or null when the field is still pure).
<!-- tomevault:4.0:skill_md:2026-05-22 -->Source: abdallhMoukdad/flutter-bloc-skills — distributed by TomeVault.
- 流狐分类
- 工程开发
- 作者声明 Agent
- 未找到明确声明;不据此推断已兼容或已测试
- 静态检查
- 88 / 100 · 启发式扫描,不代表运行安全
- 作者 / 版本 / 许可
- @tomevault-io · 未声明 license
- 流狐 Token 估算
- 较高消耗
- 流狐接入估算
- 需简单配置
- 是否需要外部 API Key
- 未发现要求
- 检测到的系统要求
- 未声明
- 底层运行要求
- 未声明
- 检测到的文件与系统行为
-
- 只读
- 允许写入 / 修改
- 检测到的网络行为
- 仅限本地
- 安装命令数
- 无(仅作为资料)
档案由构建时根据 SKILL.md 与安装命令自动衍生,可能与作者实际意图存在差异。
需要注意: 未限定 allowed-tools,默认拥有全部工具权限。
作者没有在当前 SKILL.md 中定义固定输出样例。 Why formz FormzInput per field Form Bloc state shape
A naive form Bloc holds raw String fields and runs validation in the submit handler. That has three failure modes: (1) validation rules duplicate across forms that share a field (email validator written three times); (2) the View has to know whether to display…
A field is a sealed enum of failure modes plus a FormzInput subclass. .pure(value) is for the initial state — field is untouched, no error displayed. .dirty(value) is for every subsequent update from user input — the validator runs and displayError will…
The state holds all fields as inputs, an overall submission status, and a slot for server-level failures that aren't per-field. A getter isValid derives from Formz.validate([...]) rather than being stored. The View consults state.isValid to decide whether the…
When flutter-bloc-async-api's repository returns Result.failure(Failure.validation(errors)) — the Failure taxonomy defined in flutter-bloc-async-api — the form Bloc splatters those errors back onto the corresponding inputs.
Workflow: Build a form with Bloc and formz
# Forms with Bloc and Formz
Pushes per-field validation into typed value objects (`FormzInput<TValue, TError>`) so the Bloc only orchestrates: hold the fields, derive overall validity, submit, and route server-side validation errors back to the right field. Form fields become reusable types (`EmailInput`, `PhoneInput`) shared across screens. Submission status (`FormzSubmissionStatus.inProgress` / `success` / `failure`) is decoupled from field validity so a valid form can still fail to submit, and vice versa. Builds on `flutter-bloc-async-api` for the underlying request/response flow.
## Contents
- [Why formz](#why-formz)
- [FormzInput per field](#formzinput-per-field)
- [Form Bloc state shape](#form-bloc-state-shape)
- [Mapping Laravel 422 errors to fields](#mapping-laravel-422-errors-to-fields)
- [Workflow: Build a form with Bloc and formz](#workflow-build-a-form-with-bloc-and-formz)
- [Applied to Talabat-clone](#applied-to-talabat-clone)
- [Examples](#examples)
## Why formz
A naive form Bloc holds raw `String` fields and runs validation in the submit handler. That has three failure modes: (1) validation rules duplicate across forms that share a field (email validator written three times); (2) the View has to know whether to display an error (don't show "required" before the user touches the field); (3) server-side and client-side errors are tracked separately and drift apart.
`formz` solves all three by making each field a value object:
- A `FormzInput<TValue, TError>` knows its current value, its validity, and whether it has been touched.
- `displayError` returns the error only if the field is `dirty` — the View can naively render it without an "if pure return null" check.
… 作者原文负责流程事实;流狐只索引当前章节、要点、文件与命令。
章节 -> Contents → Why formz → FormzInput per field → Form Bloc state shape → Mapping Laravel 422 errors to fields → Workflow: Build a form with Bloc and formz
要点 -> all fields as inputs · not · Submission status is independent of validity. · Stale-server-error invariant. · Step 1 — Add the dep. · Step 2 — Decide which inputs are reusable. · Step 3 — Write the FormzInput subclasses. · Step 4 — Define the form state.
文件/命令 -> FormzInput<TValue, TError> · EmailInput · PhoneInput · FormzSubmissionStatus.inProgress · success · failure · flutter-bloc-async-api · String
内容 SHA-256 -> d5aa523d261a
方法与流程
适用与边界
原文中的明确线索
FormzInput<TValue, TError>、EmailInput、PhoneInput、FormzSubmissionStatus.inProgress、success、failure、flutter-bloc-async-api、String