Write Your First Feature¶
This page uses a complete login feature to demonstrate how to wire the template's packaged building blocks together to write business logic. Each step corresponds to one abstraction point; follow it and you can reuse the same pattern in any feature.
Generate the skeleton first:
Directory after generation:
lib/features/login/
├── login_module.dart # DI registration (only the Repository)
├── data/
│ ├── models/login_model.dart # Data model (freezed)
│ └── repositories/login_repository.dart
└── presentation/
├── bloc/
│ ├── login_bloc.dart # Already mixes in the four Mixins
│ ├── login_event.dart # freezed empty shell
│ └── login_state.dart # freezed empty shell
├── effects/login_effect_handle.dart # Business side-effect handler
└── pages/
├── login_page.dart # BlocProvider + EffectListener
└── login_body.dart # Pure render content
1. Define Intent (Event) and ViewState (State)¶
login_event.dart / login_state.dart are defined with freezed; state is always immutable and only copyWith:
// login_event.dart
@freezed
abstract class LoginEvent with _$LoginEvent {
const factory LoginEvent.usernameChanged(String value) =
LoginUsernameChanged;
const factory LoginEvent.passwordChanged(String value) =
LoginPasswordChanged;
const factory LoginEvent.submit() = LoginSubmit;
}
// login_state.dart
@freezed
abstract class LoginState with _$LoginState {
const factory LoginState({
@Default('') String username,
@Default('') String password,
@Default(false) bool isSubmitting,
@Default(false) bool isSuccess,
String? nickname,
String? error,
}) = _LoginState;
}
Why doesn't State hold the DTO?
The template accepts the mainstream trade-off of "state holding the data-layer DTO directly" (see Architecture Overview). If you want the strictest layering, map XxxModel to XxxUiModel inside the Bloc before it enters State.
2. Write the Repository (extend BaseRepository)¶
The repository handles the network and parsing, and actively throws BusinessException on a business-status-code failure. BaseRepository already provides parseList / parseSingle / parseResponse / parseBusinessResponse, so you don't write JSON parsing by hand.
// login_repository.dart
class LoginRepository extends BaseRepository {
const LoginRepository({required super.client});
Future<String> login({
required String username,
required String password,
CancelToken? cancelToken,
}) async {
final response = await client.post('/login', data: {
'username': username,
'password': password,
});
// Suppose the backend returns {code:0, message:'ok', data:'nickname'}
return parseBusinessResponse<String, LoginResp>(
response,
parseBody: LoginResp.fromJson,
isSuccess: (body) => body.code == 0,
extractCode: (body) => body.code,
extractMessage: (body) => body.message,
extractData: (body) => body.data,
);
}
}
- Non-2xx HTTP →
ServerExceptionis thrown automatically and translated byErrorHandlervia the HTTP code. - HTTP 200 but
code != 0→BusinessExceptionis thrown, with text from the backendmessage. - Pure client-side validation (e.g. empty username) can also directly
throw const BusinessException('Username or password cannot be empty').
3. Write the Bloc (four Mixins working together)¶
login_bloc.dart already with the four Mixins. Combine them to complete a single "awaitable + with Loading + unified error handling" login:
class LoginBloc extends Bloc<LoginEvent, LoginState>
with
BlocAwaitMixin<LoginEvent, LoginState>,
BlocEffectMixin<LoginState>,
BlocErrorHandlerMixin<LoginState>,
BlocCancelTokenMixin<LoginState> {
LoginBloc({required this.repository}) : super(const LoginState()) {
on<LoginUsernameChanged>(_onUsernameChanged);
on<LoginPasswordChanged>(_onPasswordChanged);
onAwait<LoginSubmit>(_awaitKeySubmit, _onSubmit); // auto-finalized await
}
final LoginRepository repository;
static const String _awaitKeySubmit = 'login_submit';
/// The page can await this submission (e.g. navigate after a successful login).
Future<void> submit() =>
runAwait(event: const LoginEvent.submit(), key: _awaitKeySubmit);
Future<void> _onSubmit(LoginSubmit event, Emitter<LoginState> emit) async {
emit(state.copyWith(isSubmitting: true, error: null));
// 1) Side-effect: show global Loading (handled by the framework default handle)
emitEffect(const LoadingEffect(show: true));
// 2) Unified error handling: three explicit terminal states (success/failure/cancel)
final result = await runToResult(
() => repository.login(
username: state.username,
password: state.password,
cancelToken: token('login'),
),
);
// 3) Side-effect: hide Loading
emitEffect(const LoadingEffect(show: false));
result.when(
success: (nickname) {
emit(state.copyWith(isSubmitting: false, isSuccess: true, nickname: nickname));
emitEffect(const ToastEffect(l10nCode: 'loginSuccess'));
},
failure: (ex) {
emit(state.copyWith(isSubmitting: false, error: ex.message));
emitEffect(const ToastEffect(l10nCode: 'loginFailed'));
},
cancel: () => emit(state.copyWith(isSubmitting: false)),
);
}
}
Key points:
runToResultreplaces hand-writtentry/catch: network exceptions / cancellations are normalized, and you only handle three states.LoadingEffectdoes not go through the business handle; the framework default handle callsLoadingServiceto show/hide the global Loading.ToastEffect(l10nCode: ...)uses a custom localization key, translated by the business handle (see step 4). To show the server text directly, useToastEffect(message: ex.message)orex.toToastEffect().
4. Write the business side-effect handler (l10nCode → text)¶
login_effect_handle.dart uses is to claim the l10nCode it cares about, and does not exhaustively switch; everything else falls through to the framework default handle:
bool loginEffectHandle(BuildContext context, UIEffect effect) {
if (effect is ToastEffect && effect.l10nCode != null) {
final service = getIt<ToastService>();
final l = context.l;
switch (effect.l10nCode) {
case 'loginSuccess':
service.showSuccess(l.loginSuccess);
return true;
case 'loginFailed':
service.showError(l.loginFailed);
return true;
default:
return false;
}
}
return false; // everything else falls through to the framework default handle
}
And add the corresponding keys to l10n/app_zh.arb / app_en.arb:
context.lis a convenience extension provided by the template, equivalent toAppLocalizations.of(context).
5. Wire the Page (BlocProvider + EffectListener)¶
The skeleton's login_page.dart is already wired:
BlocProvider(
create: (_) => LoginBloc(repository: getIt<LoginRepository>()),
child: const EffectListener<LoginBloc, LoginState>(
effectsHandles: [loginEffectHandle],
child: LoginBody(),
),
)
login_body.dart is a pure function of State: it only reads context.watch<LoginBloc>(), and only emits intents via context.read<LoginBloc>().add(...) / .submit():
final bloc = context.watch<LoginBloc>();
final state = bloc.state;
// render state.username / state.isSubmitting ...
// submit: await bloc.submit(); // awaitable
// or: context.read<LoginBloc>().add(const LoginEvent.submit());
6. Run¶
Data flow after completion:
Tap login → bloc.submit() (awaitable)
→ emit(isSubmitting:true) + emitEffect(LoadingEffect(show:true))
→ runToResult(repository.login)
→ emitEffect(LoadingEffect(show:false))
→ result.when: success → emit(state)+Toast(l10nCode:loginSuccess)
failure → emit(state)+Toast(l10nCode:loginFailed)
cancel → emit(isSubmitting:false)
Pattern Recap (copy-paste ready)¶
Every feature module follows the same recipe:
fluzer new <name>generates the skeleton.Event/Stateuse freezed; state is immutable.Repository extends BaseRepository, parses withparseBusinessResponseetc. for business-status-code handling, and throwsBusinessExceptionon failure.- The Bloc
withthe four Mixins:onAwaitfor awaitable actions,runToResultfor unified error handling,emitEffect(LoadingEffect/ToastEffect)for one-shot side-effects. effects/<name>_effect_handle.dartonly translates customl10nCode; everything else falls through to the default handle.- The page only renders
Stateand only emits intents.
For finer-grained usage, see The Four BLoC Mixins, Error Handling & Result, and Effect & Notifiers.