Ark MVP Flutter
Flutter hosting for Ark MVP: stable Presenter ownership, direct or BuildContext-based ModelBinding resolution, presentation-aware ViewState, and non-replay ViewEffect delivery.
The package connects an application-defined Model to the Flutter widget tree.
It does not depend on UseCase Forge, Ark DI, Ark Data Layer, provider, or
flutter_bloc. Those tools can participate in application composition without
becoming requirements of the MVP boundary.
Version 1.0 establishes the stable public API described in this guide.
What the Flutter package adds
ark_mvp defines ModelBinding, Presenter, ViewState adaptation, effects, and
the framework-independent session. This package adds four Flutter roles:
FlutterPresenterreceives the currentBuildContextwhile building ViewState;MvpViewowns one Presenter lifecycle and renders its ViewState;MvpModelProviderexposes a stable binding through the widget tree;onEffecthandles transient UI work in the currently active context.
The resulting flow is:
Application-owned business objects
│
▼
ModelBinding<FeatureModel>
direct │ or │ MvpModelProvider
▼
MvpView creates one FlutterPresenter
│
├── Model change ──► new ViewState
├── locale/theme ──► new ViewState
├── Presenter action ──► Model operation
└── ViewEffect ──► onEffect(context, effect)
│
▼
builder(context, viewState, presenter)
Installation
dependencies:
ark_mvp: ^1.0.0
ark_mvp_flutter: ^1.0.0
Declare ark_mvp directly when application code imports ModelBinding,
Presenter, or another core type.
import 'package:ark_mvp/ark_mvp.dart';
import 'package:ark_mvp_flutter/ark_mvp_flutter.dart';
Complete minimal feature
1. Define the feature Model
The Model contains business state and allowed operations, not the concrete manager itself:
final class CounterModel {
const CounterModel({
required this.value,
required this.increment,
});
final int value;
final void Function() increment;
}
2. Define presentation output
final class CounterViewState {
const CounterViewState({
required this.valueLabel,
required this.canIncrement,
});
final String valueLabel;
final bool canIncrement;
}
sealed class CounterEffect {
const CounterEffect();
}
final class CounterLimitReached extends CounterEffect {
const CounterLimitReached();
}
3. Adapt Model in Presenter
final class CounterPresenter
extends FlutterPresenter<CounterModel, CounterViewState, CounterEffect> {
@override
CounterViewState buildViewState(BuildContext context) {
final MaterialLocalizations localizations =
MaterialLocalizations.of(context);
return CounterViewState(
valueLabel: localizations.formatDecimal(model.value),
canIncrement: model.value < 100,
);
}
void increment() {
if (model.value >= 100) {
emitEffect(const CounterLimitReached());
return;
}
model.increment();
}
}
Presenter reads localization from the current tree while building ViewState.
Ark MVP never stores that BuildContext.
4. Compose and host the feature
final ModelBinding<CounterModel> binding = ModelBinding<CounterModel>(
read: () => CounterModel(
value: counter.value,
increment: counter.increment,
),
changes: <Stream<Object?>>[counter.changes],
);
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 limit has been reached.')),
);
}
},
builder: (context, viewState, presenter) {
return CounterView(
value: viewState.valueLabel,
canIncrement: viewState.canIncrement,
onIncrement: presenter.increment,
);
},
)
CounterView receives formatted values and a valid feature action. It has no
access to CounterModel or the original counter manager.
Direct binding or BuildContext
MvpView resolves ModelBinding in this order:
- use its explicit
modelargument when present; - otherwise watch the nearest exact
MvpModelProvider<M>.
Direct binding makes a local dependency visible at the call site:
MvpView<ProfileModel, ProfileViewState, ProfileEffect, ProfilePresenter>(
model: profileBinding,
// ...
)
A provider is useful when composition and presentation are separated in the widget tree:
MvpModelProvider<ProfileModel>.value(
binding: profileBinding,
child: const ProfileScreen(),
)
The descendant omits model. For one-off reads in callbacks, use
context.readMvpModel<ProfileModel>(). For a widget that must react when the
binding instance is replaced, use context.watchMvpModel<ProfileModel>().
Lookup uses the exact Model type. Providing ProfileModel does not register
its interfaces or base classes automatically.
Presenter identity
MvpView creates Presenter once for each ModelBinding identity. The same
Presenter survives:
- ordinary parent rebuilds;
- every Model change;
- theme, locale, MediaQuery, and other inherited changes;
- replacement of builder or effect callback closures.
A new Presenter is created when:
- the resolved ModelBinding instance changes;
- Flutter replaces the
MvpViewState, including a changed widget key; - the View is removed and later inserted again.
The old Presenter is closed asynchronously before its lifecycle is discarded.
Do not return a cached Presenter from createPresenter; every invocation must
produce a new instance.
BuildContext boundary
Flutter depends on BuildContext for localization, themes, accessibility, layout, and inherited application state. Forbidding it in Presenter would move presentation conversion back into View.
FlutterPresenter.buildViewState(context) is therefore context-aware, but the
runtime does not retain context. Use it synchronously to read presentation
dependencies while ViewState is built. Public Presenter actions should receive
explicit parameters or call Model operations; they should not cache an old
context for later navigation.
One-time context work belongs in onEffect, where MvpView supplies the
currently active context.
ViewState and child widgets
The builder receives:
- current BuildContext;
- immutable presentation-ready ViewState;
- the concrete Presenter with feature actions.
It does not receive Model. Child widgets should accept ordinary values and callbacks:
ProfileView(
title: viewState.title,
avatarUrl: viewState.avatarUrl,
isSaving: viewState.isSaving,
canSave: viewState.canSave,
onSave: presenter.save,
)
Text controllers, focus nodes, scroll controllers, and animations remain in a
StatefulWidget that renders the View. They are tied to Flutter element State,
not to the business Model. Presenter may keep small logical presentation state
and call invalidateView() when it changes.
ViewEffect
Presenter emits an effect; MvpView delivers it once to onEffect:
sealed class SignInEffect {
const SignInEffect();
}
final class OpenHome extends SignInEffect {
const OpenHome();
}
final class ShowSignInFailure extends SignInEffect {
const ShowSignInFailure(this.message);
final String message;
}
onEffect: (context, effect) {
switch (effect) {
case OpenHome():
Navigator.of(context).pushReplacementNamed('/home');
case ShowSignInFailure(:final message):
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message)),
);
}
}
Effects are not replayed. An emitted effect without onEffect is reported as
MvpEffectListenerMissingException; it is never silently discarded. Use
Never when a feature has no effects.
Error handling
onError receives asynchronous failures from:
- Model change streams;
- a Model refresh after a change signal;
- Presenter invalidation and effect channels;
- subscription cancellation;
closePresenter().
Without onError, the package reports the failure through
FlutterError.reportError. Synchronous failures during initial binding,
Presenter creation, or ViewState construction remain Flutter build errors so a
feature cannot continue in a partially initialized state.
Ownership
MvpView owns Presenter. It does not own ModelBinding or the business objects
captured by it. A surrounding StatefulWidget, DI scope, UseCase owner, or
application root must close those objects according to their real scope.
MvpModelProvider owns only the stable binding value it creates. ModelBinding
itself has no close method because it is an adapter over externally owned
sources.
UseCase Forge integration
UseCase Forge is a common but optional business-state source. Composition reads
one or several UseCaseSnapshot values and exposes specific command callbacks
through Model. Neither the Model nor Presenter needs the concrete UseCase
instance.
See UseCase Forge composition and the runnable examples for complete code.
Runnable examples
- One UseCase and direct binding
- Several UseCases aggregated by one Presenter
- Independent application state manager and context binding
- Step-by-step example guide
- Flutter lifecycle
- Designing ViewState and ViewEffect
Run an example from the package root:
flutter run example/main.dart
Libraries
- ark_mvp_flutter
- Flutter widgets and BuildContext integration for Ark MVP.