state4d 0.0.2 copy "state4d: ^0.0.2" to clipboard
state4d: ^0.0.2 copied to clipboard

Simple state machine for Dart, including diagramming and async support

State4d #

Dart Pub Version License

A simple state machine library for Dart, ported from state4k.

Installation #

dependencies:
  state4d: ^1.0.0

Concepts #

To model a state machine, you need:

  1. Entity - The object being modeled, which holds the current state
  2. States - The discrete states the entity can be in
  3. Events - Triggers that cause transitions between states
  4. Commands (optional) - Actions that execute when entering or exiting a state, which may generate events

Transitions occur in one of two ways:

  • Receive a known out-of-band Event and transition to a new state
  • Receive and process a Command, which will result in one of a discrete set of Events

The storage of the controlled entity is done entirely outside of State4d - the library is purely functional and returns new entity instances.

Example: Making Tea #

import 'package:result4d/result4d.dart';
import 'package:state4d/state4d.dart';

// The entity - tracks the state
class CupOfTea {
  final TeaState state;
  final String lastAction;

  CupOfTea(this.state, this.lastAction);

  CupOfTea copyWith({TeaState? state, String? lastAction}) =>
      CupOfTea(state ?? this.state, lastAction ?? this.lastAction);
}

// The states
enum TeaState { getCup, boilingWater, steepingTea, checkForMilk, whiteTea, blackTea }

// Commands that can be triggered on state entry/exit
enum TeaCommands { doYouHaveMilk, removeBag }

// Events that trigger transitions
sealed class TeaEvent {}
class TurnOnKettle extends TeaEvent {}
class PourWater extends TeaEvent {}
class MilkPlease extends TeaEvent {}
class NoMilkPlease extends TeaEvent {}
class MilkIsFull extends TeaEvent {}
class MilkIsEmpty extends TeaEvent {}

// Lens to get/set state on the entity
final lens = StateIdLens<CupOfTea, TeaState>(
  (entity) => entity.state,
  (entity, state) => entity.copyWith(state: state),
);

// Command handler
Result<void, String> commands(CupOfTea entity, TeaCommands command) {
  print('Issuing command $command');
  return Success(null);
}

// Define the state machine
final teaStateMachine = StateMachine.create(commands, lens).states<TeaEvent>(
  (state) => [
    state(.getCup)
        .transition(TurnOnKettle, .boilingWater),

    state(.boilingWater)
        .transition(PourWater, .steepingTea,
            (event, entity) => entity.copyWith(lastAction: 'Waiting...')),

    state(.steepingTea)
        .onEnter(.doYouHaveMilk)
        .onExit(.removeBag)
        .transition(MilkPlease, .checkForMilk)
        .transition(NoMilkPlease, .blackTea),

    state(.checkForMilk)
        .transition(MilkIsFull, .whiteTea)
        .transition(MilkIsEmpty, .blackTea),

    state(.blackTea),
  ],
);

Usage #

Event-based transitions #

final result = await teaStateMachine.transition(
  CupOfTea(.getCup, '-'),
  TurnOnKettle(),
);

switch (result) {
  case Success(value: OK(entity: final tea)):
    print('New state: ${tea.state}');
  case Success(value: IllegalEvent()):
    print('Event not valid for current state');
  case Failure(reason: final error):
    print('Error: $error');
}

Command-based transitions #

Commands are useful when the resulting event depends on external factors:

final result = await teaStateMachine.transitionCommand(
  cupOfTea,
  .doYouHaveMilk,
  (entity) async {
    // Check fridge, call API, etc.
    final hasMilk = await checkFridge();
    return hasMilk ? Success(MilkIsFull()) : Success(MilkIsEmpty());
  },
);

Queued transitions with Runner #

For sequential transitions that should execute in order:

final runner = teaStateMachine.run(CupOfTea(.getCup, '-'));

runner.transition(TurnOnKettle());
runner.transition(PourWater());

final result = await runner.transition(MilkPlease());
// result contains the entity after all three transitions

Each call returns a Future - await any call to get the result up to that point:

final runner = teaStateMachine.run(initialEntity);

var result = await runner.transition(TurnOnKettle());
expect(result.value.state, .boilingWater);

result = await runner.transition(PourWater());
expect(result.value.state, .steepingTea);

The runner also supports transitionCommand:

final runner = teaStateMachine.run(initialEntity);

runner.transition(TurnOnKettle());
runner.transition(PourWater());

final result = await runner.transitionCommand(
  .doYouHaveMilk,
  (entity) async => Success(MilkIsFull()),
);

Listening for changes #

The runner supports a listener API compatible with Flutter's ChangeNotifier pattern:

final runner = teaStateMachine.run(CupOfTea(.getCup, '-'));

runner.addListener(() {
  print('State changed to: ${runner.entity.state}');
});

await runner.transition(TurnOnKettle());
// Prints: State changed to: TeaState.boilingWater

runner.dispose(); // Clean up when done

Available methods:

  • addListener(void Function() listener) - Register a callback for entity changes
  • removeListener(void Function() listener) - Unregister a callback
  • hasListeners - Check if any listeners are registered
  • dispose() - Stop notifications and clear all listeners

Flutter integration #

State4d has no Flutter dependency, but integrates easily with ValueListenableBuilder. Create a simple wrapper:

import 'package:flutter/foundation.dart';
import 'package:state4d/state4d.dart';

class StateMachineNotifier<E> extends ValueNotifier<E> {
  final StateMachineRunner _runner;

  StateMachineNotifier(StateMachineRunner runner)
      : _runner = runner,
        super(runner.entity as E) {
    _runner.addListener(_onChanged);
  }

  void _onChanged() => value = _runner.entity as E;

  @override
  void dispose() {
    _runner.removeListener(_onChanged);
    super.dispose();
  }
}

Use it with ValueListenableBuilder:

final runner = teaStateMachine.run(CupOfTea(.getCup, '-'));
final notifier = StateMachineNotifier<CupOfTea>(runner);

// In your widget tree
ValueListenableBuilder<CupOfTea>(
  valueListenable: notifier,
  builder: (context, tea, child) {
    return Text('Current state: ${tea.state}');
  },
)

// Trigger transitions from anywhere
await runner.transition(TurnOnKettle());
// Widget rebuilds automatically

Visualization #

Render the state machine using pluggable renderers. Mermaid and PlantUML are included:

print(teaStateMachine.renderUsing(renderMermaid));
print(teaStateMachine.renderUsing(renderPuml));

Output (Mermaid):

Write your own renderer by implementing StateMachineRenderer.

License #

This project is licensed under the Apache 2.0 License - see the LICENSE file for details.

Acknowledgments #

  • Original state4k Kotlin library by the fork-handles team
0
likes
150
points
8
downloads

Publisher

verified publisheronehundredandeighty.org

Weekly Downloads

Simple state machine for Dart, including diagramming and async support

Repository (GitHub)
View/report issues

Topics

#state-machine #testing

Documentation

API reference

License

Apache-2.0 (license)

Dependencies

result4d

More

Packages that depend on state4d