flutter_solo 1.0.0
flutter_solo: ^1.0.0 copied to clipboard
State management for Flutter: sequential jobs, exclusive state ownership, cooperative cancellation, rebuilds through SoloBuilder or ValueListenable.
flutter_solo #
State management for Flutter: sequential jobs over one state, exclusive
ownership, cooperative cancellation, and rebuilds through SoloBuilder or
ValueListenable.
A controller owns a state and runs jobs over it one at a time. SoloBuilder
rebuilds a widget on every change of that state. Mix SoloListenable into the
controller, and it is a ValueListenable of its state as well, so it drops
straight into ValueListenableBuilder, ListenableBuilder, AnimatedBuilder
and Listenable.merge.
The documentation site carries this page and the guides of the packages below it.
Why #
A screen has a lifecycle, and so does the work on it. A load must be dropped
when the user leaves, a save must not be cut in half, and a second tap must not
start a second request. setState and ChangeNotifier give you somewhere to
keep the state and say nothing about the work. Bloc schedules the work and
asks for an event class per call; Cubit keeps plain methods and schedules
nothing.
Here a method stays a method, and it hands back a handle:
final job = controller.load();
await job.cancel(); // returns when the job has actually stopped
print(job.outcome); // Cancelled(manual)
What the engine holds to:
- One root job of a controller at a time, so two of them never write the same state.
- Rules instead of flags: a job declares the states it works with and the condition it lives under, and it is cancelled when they stop holding.
- A cancellation the caller can ask for: the next checkpoint of the context
throws it, and a body that never reaches one still ends
Cancelledand writes no state of its own. - An outcome for every job, one of
Done,FailedandCancelled, which is what a screen has to show anyway.
The long argument, eleven scenarios solved in bloc first and then here, is in solo and bloc, side by side.
What is not here: no SoloProvider and no code generation, no dependency
injection, no persistence, and no parallel root jobs. Dependencies are left out
deliberately: they are passed by other means and packages, such as
scopo. One job at a time is deliberate too,
hence the solo in the name: it is the subject of the package, not a limit of
its engine.
Install #
flutter pub add flutter_solo
One dependency is all it takes: flutter_solo re-exports the whole of
solo, which re-exports the whole of
async_job. Solo, SoloContext, Job,
Outcome and Policy all arrive with
import 'package:flutter_solo/flutter_solo.dart';
The package's own part arrives with the same import: the mixin
SoloListenable, the widgets SoloBuilder and SoloSelector, and the class
SoloSelection. A second import next door adds select and listen as
methods, with the subscriptions listen hands back:
import 'package:flutter_solo/listenable.dart';
Methods from a second import says why it is a second one.
The analyzer plugin solo_lints reports
most of the
common mistakes
of solo in the code. Name it in analysis_options.yaml:
plugins:
solo_lints: ^0.1.3
That turns on its warnings; the lints are turned on by name, as its
Install
section shows. From Flutter 3.44 on, flutter analyze shows none of its
diagnostics: run dart analyze or read them in the editor.
Usage #
ProfileApi stands for the app's own service: fetchName() returns a
Future<String>, and saveName(name) a Future<void>. A call of
fetchName() that fails throws an ApiException.
import 'package:flutter/material.dart';
import 'package:flutter_solo/flutter_solo.dart';
sealed class Profile {
// What a Save button asks; only a loaded profile has anything to save.
bool get canSave => this is Loaded;
}
final class Empty extends Profile {}
final class Loading extends Profile {}
final class Loaded extends Profile {
final String name;
Loaded(this.name);
}
final class ProfileController extends Solo<Profile> {
final ProfileApi api;
ProfileController(this.api) : super(Empty());
Job<String> load() => enqueue<Profile, String>(
key: 'load',
policy: Policy.droppable, // a second tap returns the first job
(ctx) async {
ctx.emit(Loading());
final name = await ctx.abandonable(api.fetchName);
ctx.emit(Loaded(name));
return name;
},
isExpectedError: (error) => error is ApiException,
// Without these a load that fails or is cancelled leaves the state
// on Loading: a spinner, and nothing on the screen to tap.
ifFailed: (state, error, stackTrace) => Empty(),
ifCancelled: (state, cancelled) => Empty(),
);
Job<void> save() => enqueue<Loaded, void>(
key: 'save',
(ctx) => ctx.join(() => api.saveName(ctx.state.name)),
);
}
class ProfileView extends StatelessWidget {
final ProfileController controller;
const ProfileView({required this.controller, super.key});
@override
Widget build(BuildContext context) => SoloBuilder<Profile>(
solo: controller,
builder: (context, state, _) => switch (state) {
Empty() => TextButton(
onPressed: controller.load,
child: const Text('Load'),
),
Loading() => const CircularProgressIndicator(),
Loaded(:final name) => Text(name),
},
);
}
enqueue<Profile, String> says the job works with Profile states and returns
a String. save narrows that to Loaded: it does not start in any other
state, it ends Cancelled there, and its body reads a Loaded with a name
in it. Inside a body:
ctx.emitis the only way to write the state.ctx.abandonableawaits likeawait, except that it gives up the moment the job is cancelled.ctx.joinwaits its call out either way, so a save is not cut in half.
ifFailed and ifCancelled are the way back. They say what state a load that
failed or was cancelled leaves behind. Without them the screen would keep the
spinner of a job that is no longer running.
They answer on the screen and not for the caller. The button drops the job that
load hands back. A failure nobody reads goes on to the zone even after
ifFailed has put Empty on the screen, unless load names it in
isExpectedError or Solo.unansweredHandler answers for it. load names
ApiException, so a call that fails goes no further than Empty: the screen
is all the answer it needs. A bug is not an ApiException, and it goes on: in
an app towards PlatformDispatcher.onError, in a widget test into the test,
which fails. Outcomes has the rule. The full API, with rules, the
queue, children and observers, is documented in
solo.
SoloBuilder is what draws the state: it takes the controller as solo,
rebuilds on every change of the state and hands child through untouched. The
table gives every way to follow a controller, and the sections below take the
others apart:
| What the screen needs | What to take |
|---|---|
| A widget drawn from the whole state | SoloBuilder, or ValueListenableBuilder with SoloListenable |
| A widget that rebuilds only when one value changes | SoloSelector |
That value as a ValueListenable a State holds |
SoloSelection |
| One widget over two controllers | Listenable.merge in a ListenableBuilder, with SoloListenable |
A callback on every change, outside build |
listen from the second import, with SoloListenable |
The controller as a ValueListenable #
The framework has builders of its own, and they take a ValueListenable. Mix
SoloListenable into the controller, and it is one:
final class ProfileController extends Solo<Profile> with SoloListenable {
// ...same as above...
}
The state is its value, and ValueListenableBuilder takes the controller as
it is:
ValueListenableBuilder<Profile>(
valueListenable: controller,
builder: (context, state, _) => Text(switch (state) {
Empty() => 'no profile',
Loading() => 'loading',
Loaded(:final name) => name,
}),
),
ListenableBuilder and AnimatedBuilder take it the same way, and
Listenable.merge joins it with other listenables. A widget over two profiles
rebuilds when either of them changes:
ListenableBuilder(
listenable: Listenable.merge([left, right]),
builder: (context, _) => Text(
left.value.canSave && right.value.canSave ? 'both loaded' : 'waiting',
),
),
SoloBuilder goes on taking it, as it takes any controller: with the mixin,
without it, or with SoloStream. One thing sets it apart from
ValueListenableBuilder: it compares controllers by identity. Given a new
controller that equals the old one by == but not by identical,
SoloBuilder moves to it, and ValueListenableBuilder goes on listening to
the old one.
In a type position, write the type argument out, SoloListenable<Profile>: the
analyzer lets a bare SoloListenable through as a SoloListenable<Object>.
The sections below on selecting and listening take the controller with the mixin.
A controller with both a stream and SoloListenable, a screen built on that
stream, and a base class of controllers with no Flutter in it are on a page of
their own, Mixins.
Selecting one value #
A widget that needs one field does not have to rebuild for the rest.
SoloSelector picks the field and rebuilds only when that field changes:
SoloSelector<Profile, bool>(
solo: controller,
selector: (state) => state.canSave,
builder: (context, canSave, _) => ElevatedButton(
onPressed: canSave ? controller.save : null,
child: const Text('Save'),
),
),
The button drops the job that save hands back, on purpose, and so does
SaveButton below. A save that fails leaves the state Loaded, and nothing on
the screen says so. save has no ifFailed, so its failure goes on to the
zone, with no Solo.unansweredHandler set, in an app towards
PlatformDispatcher.onError. ignoreFailure() would leave it unheard. A
screen that tells the user waits for save().done, as in
Outcomes.
The rules of SoloSelector:
- Picks count as changed when they are
!=, unlesschanged:answers that question itself. - The selector runs once for every change of the state, so keep it a cheap pick that gives the same answer for the same state.
solois any controller, aValueListenableor not.
changed: is handed two picks: the last one the widget accepted, as
previous, and the new one. The builder has usually been given previous, but
not always. When two changes come before a frame and the first is accepted, the
second is put against it, and the builder has not seen it yet. So where the
screen ends up depends on the changes alone, not on how they fall into frames.
The selector and changed: can be closures written in place, as the selector
is above. Such a closure is a new one on every build of the parent, and it may
pick something else. So the widget picks once more and puts the pick to
changed:. It moves no subscription for that. changed: still compares
against the last pick the widget accepted.
Where a listenable of the picked value is what you need, the same pick is made
by an object, a SoloSelection, and a State holds it in a field:
class _SaveButtonState extends State<SaveButton> {
late SoloSelection<Profile, bool> canSave = _select();
SoloSelection<Profile, bool> _select() =>
SoloSelection(widget.controller, (state) => state.canSave);
@override
void didUpdateWidget(SaveButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (!identical(widget.controller, oldWidget.controller)) {
canSave = _select();
}
}
@override
Widget build(BuildContext context) => ValueListenableBuilder<bool>(
valueListenable: canSave,
builder: (context, enabled, _) => ElevatedButton(
onPressed: enabled ? widget.controller.save : null,
child: const Text('Save'),
),
);
}
Hold the selection in a field, the way canSave is held above. One built
inside build is a new object on every build. The builder under it then
unsubscribes from the old one and subscribes to the new, and each new selection
starts from a pick of its own, so the pick a change is compared against starts
over on every build.
The initializer of the field runs once, though, and reads widget.controller
as it is at that moment. The State outlives the widget: a parent that
rebuilds can hand it a new SaveButton with another controller. Without
didUpdateWidget the button would go on taking its permission from the old
controller while its tap went to the new one. The field and its
didUpdateWidget are what SoloSelector spares you: the widget that picks
needs no State.
The source is subscribed to only while the selection has listeners, and there
is nothing to dispose of. A selection outlives its source harmlessly: value
keeps reading the final state of a closed controller.
The constructor takes a ValueListenable: here, the controller with
SoloListenable. For a controller without the mixin,
SoloSelection.from(controller, selector) builds the same selection. A
ValueListenable that is not a controller is picked from the same way, with a
SoloSelection under a ValueListenableBuilder: SoloSelector takes only a
controller.
The selector of a selection runs inside the change that triggered it, so it
should only pick: over a controller with SoloListenable it must not change
the state, start a job or close the controller.
While somebody listens, the selection picks once per change, and a listener
reads the very pick that decided whether to notify. With nobody listening,
value picks every time and keeps nothing it picked. A source that changes its
value in place keeps the object, so a listener the source calls ahead of the
selection reads the pick from before the change. A solo controller with an
immutable state never meets this.
Listening without keeping the callback #
A State wants to be told of every change and handed the new state, so it
listens with a closure that reads it.
The first attempt #
// The first attempt: dispose() removes nothing; the listener stays.
@override
void initState() {
super.initState();
widget.controller.addListener(() => _onState(widget.controller.value));
}
@override
void dispose() {
widget.controller.removeListener(() => _onState(widget.controller.value));
super.dispose();
}
The closure in dispose() is spelled like the one in initState() and is
another object. removeListener has to be given the very callback
addListener took; given anything else it removes nothing and says nothing.
The first closure stays registered and goes on being called, over a State
that is gone, until the controller is closed.
A method instead of the closure #
void _onChange() => _onState(widget.controller.value);
@override
void initState() {
super.initState();
widget.controller.addListener(_onChange);
}
@override
void dispose() {
widget.controller.removeListener(_onChange);
super.dispose();
}
A method can be named in both places. removeListener compares with ==: a
method named twice is equal to itself, and a closure written twice is not. The
cost is a method for every listener, and for every addListener a
removeListener that dispose() must not leave out.
A subscription that keeps the callback #
listen keeps the callback instead and hands back a SoloSubscription;
SoloSubscriptions cancels a group of them at once:
import 'package:flutter_solo/listenable.dart';
final _listening = SoloSubscriptions();
@override
void initState() {
super.initState();
widget.controller
.listen(() => _onState(widget.controller.value))
.addTo(_listening);
canSave.listen(() => _onCanSave(canSave.value)).addTo(_listening);
}
@override
void dispose() {
_listening.cancel();
super.dispose();
}
listen works on any Listenable: a controller with SoloListenable, a
selection, a ScrollController of the framework's own. Cancelling goes by
three rules:
- Cancelling twice does nothing the second time.
- A group that has been cancelled cancels what it is handed rather than keeping it.
- If one member refuses to let go, the others are cancelled all the same, and
every failure goes to
FlutterError.reportError.
Cancelling a group never throws. Its place is dispose(), and an exception out
of there costs the elements behind it in that frame their own dispose(),
listeners and all.
What initState subscribes to stays subscribed when the widget is handed
another controller. A State whose source can be replaced cancels the group in
didUpdateWidget and takes its subscriptions again, into a new group: the
cancelled one would cancel them on the spot.
Methods from a second import #
select and listen arrive with an import of their own, next to the one the
rest of the package comes from:
import 'package:flutter_solo/flutter_solo.dart';
import 'package:flutter_solo/listenable.dart';
final canSave = controller.select((state) => state.canSave);
final subscription = canSave.listen(() => _onCanSave(canSave.value));
They are extensions on the framework's own ValueListenable and Listenable,
where another package's select and listen sit too. Two extensions with the
same member name on one type are ambiguous at every call site. A package
carrying them into every file that imports it would break a neighbour it never
heard of. The import is the choice.
Without it the methods are gone and what they do is not:
SoloSelection(controller, (state) => state.canSave)is the same selection;SoloSelectorneeds no method at all;addListenerwith a callback you keep yourself is whatlistendoes for you.
SoloSubscription and SoloSubscriptions come with the second import only. A
controller of your own with a select method keeps it, because an extension
always steps aside for a member.
The controller's life #
Nothing closes a controller for you. There is no SoloProvider: a controller
is an object, and it lives wherever the rest of your objects live.
import 'dart:async';
class _ProfileScreenState extends State<ProfileScreen> {
final controller = ProfileController(ProfileApi());
@override
void initState() {
super.initState();
controller.load();
}
@override
void dispose() {
unawaited(controller.close());
super.dispose();
}
@override
Widget build(BuildContext context) => ProfileView(controller: controller);
}
close() cancels whatever is queued or running. A running body stops at its
next context call, which is what makes leaving the screen cheap; a running job
made with cancellable: false turns the cancellation down and runs to its end,
and a queued one is dropped. Once the close is over, it drops every listener
and stops notifying for good. Until then the listeners still hear what the
closing jobs write, from ifCancelled or from the body of a job that turned
the cancellation down. The screen does not wait for that: SoloBuilder lets go
of the controller in its own dispose. close() is safe on either side of
super.dispose(). Even with SoloListenable a controller is not a
ChangeNotifier: the method is close(), not dispose(), and it returns a
Future that completes when the job has actually stopped.
A controller shared by several screens lives where the other objects they share
live: a get_it registration, an InheritedWidget above those screens, a
scope of scopo, a field of the app object. It
is closed there, once.
A scope of scopo can also take the place of the State above. ScopeModel
creates the controller once it is mounted and hands it to dispose when it
leaves the tree, and the subtree reads it with ScopeModel.of:
import 'dart:async';
import 'package:scopo/scopo.dart';
ScopeModel<ProfileController>(
create: (context) => ProfileController(ProfileApi())..load(),
dispose: (controller) => unawaited(controller.close()),
builder: (context) => ProfileView(
controller: ScopeModel.of<ProfileController>(context, listen: false),
),
),
That dispose is synchronous, as in a State, and nothing waits for the
close. Where an asynchronous scope of scopo above it has to wait, put the
controller in AsyncDataScope. It awaits the future disposeData returns, and
a parent asynchronous scope of scopo is released only after its children:
AsyncDataScope<ProfileController>(
initData: (context, ctx) async {
final controller = ProfileController(ProfileApi());
final unregister = ctx.onDiscard(controller.close);
await ctx.abandonable(() => controller.load().value);
unregister(); // from here on, disposeData closes it
return controller;
},
disposeData: (controller) => controller.close(),
progressBuilder: (context, _) => const CircularProgressIndicator(),
errorBuilder: (context, error, stackTrace, progress) => Text('$error'),
builder: (context, controller) => ProfileView(controller: controller),
),
The scope is ready once the profile is loaded: until then it shows
progressBuilder, and a load that fails is what errorBuilder shows. Until it
is returned, the controller belongs to the body alone, since the scope hands
disposeData only a value it was given. ctx.onDiscard closes it on the two
other paths: a failed load, and a scope removed while it loads, where
ctx.abandonable stops waiting at once and the close cancels the load. The
registration goes at the handover, with no wait between it and the return.
What the parent releases outlives the jobs of the controller, a save it waits
out with ctx.join included. Under ScopeModel the parent releases it while
that save is still running. Both waits have a bound, disposeScopeTimeout of
the scope and waitForChildrenTimeout of its parent, three seconds by default.
Past it scopo reports the expiry and goes on with the release, as if it had not
waited at all. ScopeTimeout.none takes the bound off for one scope. A screen
further down reads the controller with
AsyncDataScope.of<ProfileController>(context, listen: false).data.
Outcomes #
A method hands back its job, so the screen can wait for the end of the work it started:
Future<void> _load() async {
switch (await controller.load().done) {
case Done(:final value):
if (mounted) _toast('hello $value');
case Failed(:final error):
if (mounted) _toast('$error');
case Cancelled():
break; // left the screen, and close() cancelled the job
}
}
done never throws. value gives the value, rethrows the failure of a job
that failed and throws the Cancelled of one that was cancelled. mounted
after an await is the usual Flutter rule, and it applies here too.
A job nobody looks at is not silent. An unobserved Failed reaches the zone
that created the job, unless the job names its error in isExpectedError. So a
fire-and-forget call takes one of two forms. A job that names its expected
errors, as load names ApiException, needs nothing more: the call is
controller.load(), and a bug still reaches the zone. A call that needs no
outcome at all ends with ignoreFailure(): it says the outcome is nobody's
business, even when a bug ended the job.
The same road carries the errors no outcome holds, such as the failure of work
a body handed to ctx.unattended. A controller that overrides onUnanswered,
or a Solo.unansweredHandler, answers for them instead, and for an unobserved
Failed as well.
Where each error goes
on the errors page of solo has the table of them. A SoloObserver sees such
a failure and does not take it: watching is not answering.
In a Flutter app an error that reaches the zone travels the zones outwards:
- An error zone of your own around
runAppsees it first. - Past that it reaches
PlatformDispatcher.instance.onError, if you set one. - With none set, or when the callback returns
false, the embedder gets the error, and its fallback is to print it.
What comes after is the embedder's business. The documentation of onError
promises nothing about the process, which may exit or stop responding after the
callback. A plain Dart program differs in one thing: an unhandled error ends
it, with exit code 255. That is the standalone VM's own reaction, not
Flutter's.
Notes #
| A failure of | Where it goes |
|---|---|
A listener of a controller with SoloListenable |
To FlutterError.reportError, as A base class without Flutter on the mixins page shows. |
| A listener of a controller without it | To the zone, as Where each error goes on the errors page of solo has it: SoloBuilder and SoloSelector take such a controller, but FlutterError.onError never hears of a failure of its listeners. |
| A listener of a selection | Through FlutterError, whether the controller has SoloListenable or not: a selection reports its own listeners. |
| The selector of a selection | The controller's route, one of the two rows above: the selector runs inside a listener of the controller. |
| Question | Answer |
|---|---|
| When do listeners run? | Synchronously, in subscription order, on every state change. With SoloListenable, value and currentState are the same object. There is no stream unless SoloStream is mixed in: the builders listen to the controller itself. |
| Are equal states filtered? | No. emit of a state equal to the current one still notifies: the listeners are the engine's own, and the engine compares nothing. A frame may swallow several of them, a listener will not. A selection filters its own value, which is the one a widget usually cares about, as Selecting one value shows. |
| Several controllers on one screen? | Each with its own builder, as expected. Listenable.merge([a, b]) in a ListenableBuilder covers the case where one widget depends on two; it takes controllers with SoloListenable, as The controller as a ValueListenable shows. Work of one screen that must not wait for another goes to a controller of its own: A tap that does not wait for a reload. |
Can I set value? |
There is no setter. The state belongs to the jobs; a ValueNotifier face with a setter would give it away. |
| How do I test a screen? | On the fake clock of testWidgets: a timer a job waits on, such as the delay of a fake API, fires only when the test moves the clock. Testing has the three rules that follow from it. |
Versions #
| A change | Comes in |
|---|---|
| A fix that keeps the public API | a patch release, such as 1.0.1 |
| A new class, member or optional parameter | a minor release, such as 1.1.0 |
| A removed or renamed member, a changed signature or documented behaviour | a major release, such as 2.0.0 |
| A new value of an enum or a new subclass of a sealed class | a major release, such as 2.0.0 |
From 1.0.0 on, flutter_solo follows semantic versioning: the constraint
^1.0.0 takes every minor and patch release, and they keep code written
against 1.0.0 working, but for the cases below. The public API is what
package:flutter_solo/flutter_solo.dart and
package:flutter_solo/listenable.dart export; the text of error messages and
of debug output is not part of it. A member that goes away is first marked
@Deprecated in a minor release, with the name to use instead, and is removed
in the next major one.
A minor release can add a member to a mixin you build on, such as
SoloListenable, and a member of the same name in your own code then stops
compiling. It can add a member to an interface you implement for a mock, such
as JobContext or SoloContext, and a fake that implements it by hand, rather
than through noSuchMethod, then stops compiling too. The changelog of such a
release names the members it adds. A new top-level name can clash with the same
name imported from another package, until one of the imports hides it with
hide.
flutter_solo re-exports solo, so a major release of solo or async_job
comes with a major release of flutter_solo.
solo #
solo is the controller itself: the queue and
its policies, the working type of a job, canStart and keepWhile, children,
observers, the waiting family and the rest of the API. This package re-exports
all of it, SoloStream included, whose broadcast stream is for what is not a
widget. If you are not writing widgets, take solo instead: it is pure Dart.