Skip to main content

flutter_solo 1.0.0 copy "flutter_solo: ^1.0.0" to clipboard
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 Cancelled and writes no state of its own.
  • An outcome for every job, one of Done, Failed and Cancelled, 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.emit is the only way to write the state.
  • ctx.abandonable awaits like await, except that it gives up the moment the job is cancelled.
  • ctx.join waits 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 !=, unless changed: 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.
  • solo is any controller, a ValueListenable or 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;
  • SoloSelector needs no method at all;
  • addListener with a callback you keep yourself is what listen does 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:

  1. An error zone of your own around runApp sees it first.
  2. Past that it reaches PlatformDispatcher.instance.onError, if you set one.
  3. 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.

0
likes
160
points
993
downloads

Documentation

API reference

Publisher

verified publisheryet-another.dev

Weekly Downloads

State management for Flutter: sequential jobs, exclusive state ownership, cooperative cancellation, rebuilds through SoloBuilder or ValueListenable.

Repository (GitHub)
View/report issues

Topics

#state-management #async #cancellation #concurrency

License

BSD-3-Clause (license)

Dependencies

flutter, solo

More

Packages that depend on flutter_solo