trigger and wait for item creation in different BLoC

Issue

My approach below feels way to complicated for a simple thing I am trying to achive:

I have a list of Tasks that is managed by a TaskBloc. The UI lists all tasks and provides an execute button per task. For each click on that button I want to create and store an Action (basically the timestamp when the task-execution happened) and show a spinner while the action is created. I have an ActionBloc that manages the actions (e.g. creation or getting the history per task).

I am confused how to setup the communication between the BLoCs.


This is my approach so far.

The ActionsState just holds a list of all stored actions.

class ActionsState extends Equatable {
  final List<Action> actions;
  // ... copyWith, constructors etc.
}

Action is a simple PODO class holding an id and timestamp.

The ActionsBloc is capable of creating Actions in response to it’s ActionCreationStarted event (holding a int taskId). Since the Action creation is performed in an async isolate there are also events ActionCreationSucceeded and ActionCreationFailed that are added by the isolate once the request finished. Both hold the Action that was either created or whose creation failed.

The TaskState:

class TaskState extends Equatable {
  final Map<int, Task> tasks;
  // ... copyWith, constructors, etc.

I added a executeStatus to the Task model to keep track of the status of the create request in the task list (a specific task cannot be executed multiple times in parallel, but only sequentially while different tasks can be executed in parallel):

enum Status { initial, loading, success, error }

class Task extends Equatable {
  final int id;
  final Status executeStatus;
  // ...
}

I added events for the TaskBloc:

class TaskExecutionStarted extends TaskEvent {
  final int taskId;
  // ...
}
class TaskExecutionSucceeded extends TaskEvent {
  final int taskId;
  // ...
}
class TaskExecutionFailed extends TaskEvent {
  final int taskId;
  // ...
}

In the TaskBloc I implemented the mapEventToState for the new events to set the task status depending on the event, e.g. for TaskExecutionStarted:

Stream<TaskState> mapEventToState(TaskEvent event) async* {
  // ...
  if (event is TaskExecutionStarted) {
    final taskId = event.taskId;
    Task task = state.tasks[taskId]!;
    yield state.copyWith(
      tasks: {
        ...state.tasks,
        taskId: task.copyWith(executeStatus: Status.loading),
      },
    );
  }
  // ...
}

So far this enables the UI to show a spinner per Task but the ActionBloc does not yet know that it should record a new Action for that task and the TaskBloc does not know when to stop showing the spinner.


PROBLEM

Now the part where I am lost is that I need to actually trigger the ActionBloc to create an action and get an TaskExecutionSucceeded (or ...Failed) event afterwards. I thought about using a listener on the ActionsBloc, but it only provides the state and not the events of the ActionsBloc (I would need to react to the ActionCreationSucceeded event, but listening to events of an other bloc feels like an anti-pattern (?!) and I do not even know how to set it up).

The core of the problem is, that I may listen on the ActionsBloc state but I don’t know how to distinguish for which actions of the state I would need to trigger a TaskExecutionSucceeded event.

Anyway, I gave the TaskBloc a reference to ActionsBloc:

class TaskBloc extends Bloc<TaskEvent, TaskState> {
  final ActionsBloc actionsBloc;
  late final StreamSubscription actionsSubscription;
  // ...
  TaskBloc({
    // ...
    required this.actionsBloc,
  }) : super(TaskState.initial()) {
    actionsSubscription = actionsBloc.listen((state) {
      /* ... ??? ... Here I don't know how to distinguish for which actions of the state
           I would somehow need to trigger a `TaskExecutionSucceeded` event. */
    });
  };
  // ...
}

For the sake of completeness, triggering creation of the Action is simple by adding the corresponding event to the ActionBloc as response to the TaskExecutionStarted:

Stream<TaskState> mapEventToState(TaskEvent event) async* {
  // ...
  // ... set executeStatus: Status.loading as shown above ...
  // trigger creating a new action
  actionsBloc.add(ActionCreationStarted(taskId: taskId));
  // ...

Of course I aim at clear separation of concerns, single source of truth and other potential sources for accidential complexity regarding app state structure – but overall this approach (which still has said problem unsolved before working) feels way to complicated just to store a timestamp per action of a task and keep track of the action-creation-request.

I appreciate that you read so far (!) and I am very happy about hints towards a clean architecture for that use case.

Solution

So what we ended up doing is the following:

Introduce a lastCreatedState in ActionsState that represents the status of the last created action.

Instead of always listening to the ActionsBloc all the time we listen to its state temporarily when task execution is happening and remember the listener per event.

Once we got a change in the ActionsBloc lastCreatedState state that indicates success or failure of our task we remove the listener and react to it.

Something along the lines of this:

/// When a task is executed, we trigger an action creation and wait for the
/// [ActionsBloc] to signal success or failure for that task.
/// The mapping maps taskId => subscription.
final Map<int, StreamSubscription> _actionsSubscription = {};

Stream<TaskState> mapEventToState(TaskEvent event) async* {

  // ...

  // trigger creation of an action
  actionsBloc.add(ActionCreationStarted(taskId: taskId));
  // listen to the result
  // remove any previous listeners
  if (_actionsSubscription[taskId] != null) {
    await _actionsSubscription[taskId]!.cancel();
  }
  StreamSubscription<ActionsState>? listener;
  listener = actionsBloc.stream.listen((state) async {
    final status = state.lastCreatedState?.status;
    final doneTaskId = state.lastCreatedState?.action?.taskId;
    if (doneTaskId == taskId &&
        (status == Status.success || status == Status.error)) {
      await listener?.cancel(); // stop listening
      add(TaskExecutionDone(taskId: taskId, status: status!));
    }
  });
  _actionsSubscription[taskId] = listener;
}

@override
Future<void> close() {
  _actionsSubscription.values.forEach((s) {
    s.cancel();
  });
  return super.close();
}

It is not perfect: It requires the pollution of the ActionsState and it requires the TaskBloc to not be disposed before all listeners have finished (or at least have other stuff that ensures the state is hydrated and synced on creation) and the polluted sate.

While the internals are a little more complicated it keeps things separated and makes using the blocs a breeze. 🌈

Answered By – Stuck

Answer Checked By – David Marino (FlutterFixes Volunteer)

Leave a Reply

Your email address will not be published.