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:

  • FlutterPresenter receives the current BuildContext while building ViewState;
  • MvpView owns one Presenter lifecycle and renders its ViewState;
  • MvpModelProvider exposes a stable binding through the widget tree;
  • onEffect handles 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:

  1. use its explicit model argument when present;
  2. 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 MvpView State, 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:

  1. current BuildContext;
  2. immutable presentation-ready ViewState;
  3. 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

Run an example from the package root:

flutter run example/main.dart

Libraries

ark_mvp_flutter
Flutter widgets and BuildContext integration for Ark MVP.