Tutorials
Build a feature around one UseCase
Connect one UseCase snapshot to a Model, adapt it with Presenter, and render it through a directly bound MvpView.
Build a feature around one UseCase
This counter feature uses UseCase Forge as an optional business-state source. The MVP packages do not depend on it; application composition imports both systems.
What you will build
CounterUseCase owns CounterState and command execution
↓ ModelBinding reads one complete CounterModel
CounterPresenter converts snapshot + BuildContext to CounterViewState
↓
MvpView renders state and delivers CounterEffect once
Install the packages used by this example:
flutter pub add ark_mvp:^1.1.0 ark_mvp_flutter:^1.1.0
flutter pub add usecase_forge:^1.1.0
The UseCase already exposes UseCaseSnapshot<CounterState> and accepts IncrementCounter. This tutorial begins at the presentation composition boundary; the complete UseCase is available in the linked source.
1. Own the business object
A surrounding StatefulWidget creates CounterUseCase and closes it from dispose(). MvpView will not assume that ownership.
2. Define the feature Model
final class CounterModel {
const CounterModel({
required this.snapshot,
required this.increment,
});
final UseCaseSnapshot<CounterState> snapshot;
final void Function(int amount) increment;
}
The concrete UseCase does not cross the boundary. Model carries the current snapshot and one permitted operation.
3. Create one stable binding
late final ModelBinding<CounterModel> binding = ModelBinding(
read: () => CounterModel(
snapshot: counter.state,
increment: (amount) => counter.add(IncrementCounter(amount)),
),
changes: <Stream<Object?>>[counter.stream],
);
Create the binding once for the feature lifecycle. Recreating it on every Flutter build intentionally creates a new Presenter lifecycle.
4. Adapt state in Presenter
Define a ViewState that contains everything the widget needs to render:
final class CounterViewState {
const CounterViewState({
required this.valueLabel,
required this.statusLabel,
required this.canIncrement,
required this.detailsExpanded,
});
final String valueLabel;
final String statusLabel;
final bool canIncrement;
final bool detailsExpanded;
}
sealed class CounterEffect {
const CounterEffect();
}
final class CounterLimitReached extends CounterEffect {
const CounterLimitReached();
}
Presenter maps value and execution phase into display text and interaction policy. It can use MaterialLocalizations.of(context) while building ViewState because that context is current and is not retained.
final class CounterPresenter
extends FlutterPresenter<CounterModel, CounterViewState, CounterEffect> {
bool _detailsExpanded = false;
void increment() => model.increment(1);
void explainLimit() => emitEffect(const CounterLimitReached());
void toggleDetails() {
_detailsExpanded = !_detailsExpanded;
invalidateView();
}
@override
CounterViewState buildViewState(BuildContext context) {
final snapshot = model.snapshot;
final localizations = MaterialLocalizations.of(context);
return CounterViewState(
valueLabel: localizations.formatDecimal(snapshot.state.value),
statusLabel: switch (snapshot.phase) {
UseCaseExecutionPhase.processing => 'Processing command…',
UseCaseExecutionPhase.finished => 'Command completed',
_ => 'Ready',
},
canIncrement: snapshot.state.value < 10 &&
snapshot.phase != UseCaseExecutionPhase.processing,
detailsExpanded: _detailsExpanded,
);
}
}
The public increment() method either invokes the Model operation or emits CounterLimitReached. View does not repeat the limit rule.
5. Render ready values
Pass the stable binding directly to MvpView. Its builder receives current ViewState and the concrete Presenter. Handle the effect with the active BuildContext:
MvpView<CounterModel, CounterViewState, CounterEffect, CounterPresenter>(
model: binding,
createPresenter: CounterPresenter.new,
onEffect: (context, effect) {
switch (effect) {
case CounterLimitReached():
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('The counter limit is 10.')),
);
}
},
builder: (context, state, presenter) => CounterView(
value: state.valueLabel,
status: state.statusLabel,
onIncrement: state.canIncrement
? presenter.increment
: presenter.explainLimit,
onToggleDetails: presenter.toggleDetails,
),
)
CounterView receives strings, booleans, and callbacks. It cannot inspect the Model or derive policy from UseCaseExecutionPhase.
6. Close the object that you created
@override
void dispose() {
unawaited(counter.close());
super.dispose();
}
MvpView closes its Presenter session. The surrounding StatefulWidget closes the UseCase because it created that business object.
7. Verify the boundary
- View cannot read
CounterModel; - Presenter cannot send an undeclared command;
MvpViewowns Presenter;- the surrounding widget owns and closes the UseCase;
- rebuilding for locale or theme preserves Presenter and rebuilds ViewState.
Run the linked complete example and press the button until the value reaches ten. Business changes arrive through ModelBinding; formatting and button policy are rebuilt by Presenter; the limit message is delivered once as ViewEffect.