mvvm_lite 1.0.0
mvvm_lite: ^1.0.0 copied to clipboard
A tiny, zero-dependency MVVM toolkit for Flutter — ViewModel base class with lifecycle tracking, scoped provider, granular ViewModelSelector, and ViewModelBuilder.
1.0.0 #
First stable release. The API surface is now committed to: additions come in minor versions, removals only in 2.0.0.
Coming from 0.2.0, this release renames the widget layer, hardens the view model lifecycle, and raises the SDK floor. There are no deprecated aliases — old names are compile errors, not warnings.
Renamed #
| 0.2.0 | 1.0.0 |
|---|---|
Consumer<S> |
ViewModelBuilder<S> |
Selector<S, T> |
ViewModelSelector<S, T> |
Selector.shouldRebuild |
ViewModelSelector.buildWhen |
context.readVm<VM>() |
context.viewModel<VM>() |
ConsumerBuilder<S> |
ViewModelWidgetBuilder<S> |
SelectorBuilder<T> |
SelectorWidgetBuilder<T> |
StateSelector<S, T> |
StateProjection<S, T> |
SelectorShouldRebuild<T> |
ViewModelBuilderCondition<T> |
Consumer only ever built, so it is a builder. StateSelector read as a
sibling of the Selector widget while actually being the projection function.
readVm lost its suffix because there is no watch to contrast it with —
subscriptions belong to the widgets.
Added #
ViewModelListener<S>runs a side effect on state changes without rebuilding anything — navigation, snack bars, dialogs, analytics. ItslistenWhenreceives the previous and the next state, which makes edge-triggered effects expressible:(previous, next) => !previous.done && next.donefires once on the transition, where anext.donecheck inside the effect keeps firing on every later change. That level-versus-edge slip is the failure this widget exists to prevent. The README's side-effects section now separates the three cases that usually hide behind one "navigation event" field: results of a tap (return them from the method), transitions of real state (this widget), and genuine one-shot messages (a nullable field that must be cleared).ViewModelProvider.valueexposes a view model the provider must not own —disposeis never called for it. This is what makes pages testable: pump a page with a fake or pre-seeded view model and keep the lifecycle in the test. README gained a Testing section covering both view-model and widget tests.bindStreamgained optionalonError,onDoneandcancelOnErrorparameters and returns theStreamSubscription, so a subscription can be cancelled early without keeping a separate field. OmittingonErrorkeeps Dart's default (the error reaches the surroundingZone). All three callbacks are skipped once the view model is disposed.ViewModelBuildergainedbuildWhen, so all three widgets now share one shape: a callback that receives data — the state for the builder and the listener, the projected value for the selector — and a*Whengate over the previous and next of whatever that callback consumes.previousis the state the widget was last built with, so a change it declined never becomes a baseline. None of the gates apply to the first pass: a builder and a selector always render once — they have to put something on screen — while a listener never fires for the state that was already there when it mounted.ViewModel.mountedis public rather than protected: tests and owning code legitimately need to ask whether a view model is still alive.ViewModelProviderreports its view model, current state and ownership throughdebugFillProperties, so page state is visible in the Flutter inspector.
Changed #
ViewModelProviderlost its state type parameter and is nowViewModelProvider<VM>, inferred fromcreate. Lookups match the view model by assignability at runtime rather than by exact generic type, which removes two whole failure modes:- Below Dart 3.7 the language could not infer the old
Sfrom the view model's bound, soViewModelProvider(create: ...)written without explicit type arguments silently resolvedStodynamic, and every widget beneath it then failed at runtime with a message pointing at the wrong thing. With noSto infer, the trap is gone at any language version — the runtime type of the view model always carries the real state type. - A fake that subclasses the real view model, injected through
ViewModelProvider.value, is now found bycontext.viewModel<RealVm>(). Before, the scope was registered under the fake's exact type and the documented testing recipe threw on the first interaction.
- Below Dart 3.7 the language could not infer the old
- Requires Dart
^3.8.0(Flutter 3.32 and newer), up from^3.0.0. The constraint is a single number now: theflutter:bound is gone, because the Dart version follows from the Flutter release and declaring both invites a pair that contradicts itself. 3.8 is the lowest version at which this repository verifies itself —flutter_lintsresolves there and everything compiles and formats unchanged. - Writing
stateafterdispose()throws a descriptiveFlutterErrorin both debug and release builds, and leaves the state untouched. Previously the field was assigned beforeChangeNotifierraised its debug-only assertion, so a disposed view model ended up holding a state nobody could observe — and in release the write landed silently. bindStreamthrows when called afterdispose(); such a subscription would never be cancelled.ViewModelProviderthrows a descriptiveFlutterErrorwhen neither (or both) ofchild/builderis given, instead of relying on the constructor assertion that release builds strip.ViewModel<S>implementsValueListenable<S>, so a view model can be handed toValueListenableBuilder,ListenableBuilderor anything else in the framework that accepts one. Breaking for any subclass that already declared a member namedvalue.ViewModelListenerruns its effect once per logical state change. AChangeNotifierre-enters its listener list, so an effect that writes state — clearing a one-shot event, for instance — used to make every listener registered after it fire twice, the second time withprevious == next. Those repeats are now skipped.- Switching a mounted
ViewModelProviderbetween the default and.valueconstructors is supported instead of asserted against: the widget disposes what it created and adopts what it is handed, and never disposes a view model it does not own. Previously the debug assertion left the created view model leaked and the release build disposed the caller's. bindStreamhands back a subscription that removes itself from the view model's list when cancelled or when its stream completes, so a view model that binds and cancels repeatedly no longer accumulates dead subscriptions until dispose.ViewModelSelectorskips rebuild work once unmounted, matchingViewModelBuilder, and itsbuildWhenis now also consulted when a new selector closure arrives from a parent rebuild — previously any parent rebuild slipped a new value past the gate.ViewModelBuilderdoes the same for a changedbuildWhen, so relaxing a gate no longer leaves the widget frozen until the next notification.- A consumer that loses its provider reports from
buildrather than fromdidChangeDependencies. The latter runs outside the framework's build-error recovery, so the widget stayed permanently unbuilt — one console error, no error widget, and its subscription retained. ViewModelProviderdisposes exactly the view model it created. Handing a provider back the instance it had created —createthen.valuewith that same object — used to leave it alive with nothing left to dispose it.disposeno longer lets a failingcancel()abort the rest: a stream whoseonCancelthrows used to skip the remaining subscriptions andsuper.dispose(), or surface as an uncaught asynchronous error.- The subscription returned by
bindStreamkeeps its bookkeeping when the caller replacesonDoneor callsasFuture; both used to silently drop it.
Documentation #
createruns ininitState, which permitscontext.viewModeland service-locator lookups but notTheme.of/MediaQuery.of/context.watch. Documented, with the workaround, and pinned by a test.- The side-effects section explains why a one-shot event must be cleared after handling (equal states don't notify, so an uncleared event can never fire twice) and why a page below the top of the stack has to check that it is still the current route before navigating.
- Fixed a claim that the bundled example uses
get_it— it doesn't. - Resolution by state type is now stated as a contract rather than advice, with
the wrapper-type example and two things that surprise people: extension types
are erased, so
ViewModel<SavedCount>andViewModel<int>are the same type at runtime and the wrapper buys nothing; and generics are covariant, so a builder keyed on a supertype binds to a provider of its subtype.
Tooling #
- GitHub Actions CI: format, analyze, package and example tests, publish dry
run and
panaon stable, plus a job running the suite on the exact declared floor. example/.pubignoredrops the generated platform folders from the published archive: 290 KB to 26 KB, with the pub.dev "Example" tab intact. It has to live inexample/, never in the repository root — a root-level.pubignorereplaces the root.gitignorefor pub and pullsbuild/back in. The same replacement applies one level down, socoverage/is listed explicitly:example/.gitignoreno longer has any effect on what is published.CONTRIBUTING.md.- Test suite grew from 17 to 57 tests.
- Dependabot keeps the CI actions current; the package itself has no dependencies to watch.
- Lints come from
flutter_lints, the official set for Flutter packages, instead of the language-onlylints. It adds ten Flutter-specific rules (use_build_context_synchronously,use_key_in_widget_constructors,no_logic_in_create_stateamong them); the code passed all of them without a change.
0.2.0 #
- Behavior change:
ViewModelProvidernow creates its view model ininitStateinstead of lazily on first build. This fixes a latent bug where a throwingcreate(or a throw during the first build) re-rancreatea second time duringdispose, masking the original error. The view model is now created exactly once. - Behavior change:
context.readVm<VM>(),Consumer, andSelectornow throw a descriptiveFlutterErrorwhen no matchingViewModelProvideris in scope — in both debug and release builds. Previously this was a debug-onlyassert, so release builds surfaced an opaque null-check error instead. - Documented that
Consumer/Selectorresolve the view model by state type (nearest provider wins), whereasreadVmresolves by view-model type — use a dedicated state class per provider. - Tooling: bumped the
lintsdev-dependency to^6.1.0and enabled thedirectives_orderingandalways_declare_return_typeslints. No change to the supported SDK floor (Dart >=3.0.0,Flutter >=3.10.0).
0.1.0 #
Initial release.
ViewModel<S>—ChangeNotifier-based base class with immutable state,mountedlifecycle flag, and abindStreamhelper for "subscribe once, listen forever" patterns.ViewModelProvider<VM, S>— widget that creates, scopes, and disposes a view model for a subtree. Supports eitherchildorbuilder.Consumer<S>— rebuilds on every state change.Selector<S, T>— rebuilds only when a derived projection changes; supports a customshouldRebuildfor value-equality-less types.BuildContext.readVm<VM>()— retrieves the view model without subscribing.