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:
fluzer new login # BLoC pattern by default
fluzer new login --cubit # optional: Cubit pattern (MVVM, presentation/cubit/, no event)
Directory after generation (BLoC pattern by default; with --cubit, presentation/bloc/ becomes presentation/cubit/ and there is no event.dart):
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. The framework does not parse the response for you — everything about "sending the request / parsing / judging business errors / throwing" is decided in LoginRepository's public methods (see Error Handling & Result). BaseRepository only holds Dio and provides no parse* helper.
// login_repository.dart
class LoginRepository extends BaseRepository {
const LoginRepository({required super.dio});
Future<String> login({
required String username,
required String password,
CancelToken? cancelToken,
}) async {
final response = await dio.post('/login', data: {
'username': username,
'password': password,
});
// Suppose the backend returns {code:0, message:'ok', data:'nickname'}
// The framework does not parse for you: response structure, business-code
// checks, and how to throw are all your decision.
final body = response.data as Map<String, dynamic>;
if (body['code'] != 0) {
// business code != 0 → throw Exception; text from backend message,
// handled by the Bloc's failure branch
throw Exception(body['message']?.toString() ?? 'login failed');
}
return body['data'] as String; // nickname
}
}
- Non-2xx HTTP → Dio throws
DioExceptiondirectly; for a unified prompt, useex.toToastEffect()in the Bloc'sfailurebranch (the framework does no auto-translation). - HTTP 200 but a non-zero business code → throw
Exception(text from the backendmessage), handled by the Bloc'sfailurebranch. - Pure client-side validation (e.g. empty username) can also directly
throw Exception('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>(_onSubmit); // auto-finalized await
}
final LoginRepository repository;
/// The page can await this submission (e.g. navigate after a successful login).
/// Repeated calls merge into the same submission instead of firing a second request.
Future<void> submit() =>
runAwait(event: const LoginEvent.submit());
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 runCatching(
() => 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:
runCatchingreplaces hand-writtentry/catch: network exceptions / cancellations are wrapped into the three-stateResult; you only handle the three states, andFailurecarries the rawException(no normalization).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))
→ runCatching(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 (BLoC pattern by default, or--cubitfor the Cubit pattern).Event/Stateuse freezed; state is immutable.Repository extends BaseRepository, sends requests directly viadio, parses the response manually, and throwsExceptionas needed — the framework makes no parsing assumption.- The Bloc
withthe four Mixins:onAwaitfor awaitable actions,runCatchingfor 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.
Source of this page: docs/en/getting-started/your-first-feature.md