Using BLoC pattern with service layer

Using BLoC pattern with service layer
This article has been updated! Tap to see
  • April 1, 2022: Updated the code samples in the article to use null safety

A BLoC that only processes events from a widget and emits states based on its own logic is fairly simple once you know the concept. As soon as other components such as services are involved, things get a little bit more complicated. This tutorials aims for an understandable explanation on how to add a service dependency to a BLoC.

In order for this tutorial to mainly focus on the connection to the service, we use an already existing project to start from. This also serves the purpose of increasing the level of comprehensibility as it’s not only theory. The project this tutorial is based on is the basic calculator whose code can be found here. Its creation was documented in this tutorial, applying the BLoC pattern in this tutorial.

Flutter calculator with history
The final calculator displaying its history

Theory

What we have so far is a calculator that enables the user to execute basic operations. Let’s extend it by a history of executed calculations. Since these calculations should persist over multiple app starts, we use the shared_preferences to store them. The resulting call hierarchy is supposed to look like this:

Flutter BLoC service layer call hierarchy
Flutter BLoC service layer call hierarchy

Implementation

First thing we are going to do is implementing the service. We know both of the functionalities this service should have: storing a calculation (adding it to the existing history) and fetching the existing history.

The shared preferences package only supports primitive data types. That’s why we need to serialize the data we want to store. This way, we can just store and load a string that represents the list of calculations.

Extending our model

To be able to serialize it, we need to extend the CalculationModel:

 1import 'package:equatable/equatable.dart';
 2
 3class CalculationModel extends Equatable {
 4...
 5
 6  CalculationModel.fromJson(Map<String, dynamic> json)
 7      : firstOperand = json['firstOperand'],
 8        operator = json['operator'],
 9        secondOperand = json['secondOperand'],
10        result = json['result'];
11
12  Map<String, dynamic> toJson() =>
13      {
14        'firstOperand': firstOperand,
15        'operator': operator,
16        'secondOperand': secondOperand,
17        'result': result,
18      };
19
20...
21}

We follow the proposed implementation introduced in the official docs and add a toJson() and a fromJson() method. These should be self-explanatory. Every property of the class is mapped to or from a JSON property.

Now that we have the possibility to serialize our model, we can continue with the core logic of our new feature: the service.

Implementing the service

 1import 'dart:convert';
 2
 3import 'package:flutter/material.dart';
 4import 'package:shared_preferences/shared_preferences.dart';
 5
 6import '../calculation_model.dart';
 7
 8class CalculationHistoryService {
 9  static String _sharedPreferenceKey = 'calculation_history';
10
11  CalculationHistoryService({
12    required this.sharedPreferences
13  });
14
15  SharedPreferences sharedPreferences;
16
17  List<CalculationModel> fetchAllEntries() {
18    List<CalculationModel> result = <CalculationModel>[];
19
20    if (!sharedPreferences.containsKey(_sharedPreferenceKey)) {
21      return [];
22    }
23
24    List<dynamic> history = jsonDecode(
25        sharedPreferences.getString(_sharedPreferenceKey)
26    );
27
28    for (Map<String, dynamic> entry in history) {
29      result.add(
30        CalculationModel.fromJson(entry)
31      );
32    };
33
34    return result;
35  }
36
37  Future<bool> addEntry(CalculationModel model) async {
38    List<CalculationModel> allEntries = fetchAllEntries();
39    allEntries.add(model);
40
41    return sharedPreferences.setString(
42      _sharedPreferenceKey,
43      jsonEncode(allEntries)
44    );
45  }
46}

The content of the service is quite overseeable. The most important thing: the SharedPreferences class is a constructor argument which makes it necessary to inject it into our service. This is very crucial as it guarantees the testability of our class by enabling us to mock the SharedPreferences class inside of the tests.

It’s also worth noting that we check for the existence of the key before we fetch the entries. If it’s not there, we return an empty list. So we prevent the possibility of returning null. This is a good practice when working with lists.

Other than that we just use the usual jsonEncode() and jsonDecode() functions to store and load our data. Since an array is a valid JSON payload, this serves our purpose.

Testing the service

The functionality of our service might be overseeable. However, I strongly recommend to test every component. That way, we can also verify we have implemented it in an isolated way and have covered the edge cases.

Let’s think of the test cases we have before we write the actual tests:

  • SharedPreferences.setString() is called once when storage key exists
  • SharedPreferences.getString() is called once when storage key exists (need to check for entries before adding one)
  • It properly fetches all entries from shared preferences if storage key exists
  • It properly fetches empty list if nothing has been stored yet
  • If there is an invalid JSON string, the entry should be deleted and an empty list should be returned (as a corrupt JSON can not be repaired automatically and should be invalidated)
  • When the addEntry() method is called and the shared preferences hold an empty list, the SharedPreferences should be called with a list containing only the given calculation (as the proper JSON string)
  • When addEntry() when there’s already a list, the resulting JSON string should be the combined list with the new entry as the latest entry of the serialized list

Now that’s a great thing. While listing the test cases I realized that we don’t have the functionality of invalidation and deletion yet. We will write the tests now and hope to have only one failing test whose cause we will fix in the implementation afterwards.

  1import 'dart:convert';
  2
  3import 'package:basic_calculator/calculation_model.dart';
  4import 'package:basic_calculator/services/calculation_history_service.dart';
  5import 'package:mockito/mockito.dart';
  6import 'package:shared_preferences/shared_preferences.dart';
  7import 'package:test/test.dart';
  8
  9class MockSharedPreferences extends Mock implements SharedPreferences {}
 10
 11void main () {
 12  group('Calculation history service', () {
 13
 14    final sharedPrefKey = 'calculation_history';
 15
 16    test('Shared preferences (getString) is called once when storage key exists', () async {
 17      String mockedJson = jsonEncode([]);
 18
 19      MockSharedPreferences mock = _getMockSharedPreferences(
 20          sharedPrefKey, mockedJson, true
 21      );
 22
 23      CalculationHistoryService(sharedPreferences: mock).fetchAllEntries();
 24
 25      verify(mock.getString(sharedPrefKey)).called(1);
 26    });
 27
 28    test('Properly fetches all entries from shared preferences if storage key exists', () async {
 29      CalculationModel expectedModel = _getCalculationModelAddSample();
 30      String mockedJson = jsonEncode([expectedModel.toJson()]);
 31
 32      MockSharedPreferences mock = _getMockSharedPreferences(
 33        sharedPrefKey, mockedJson, true
 34      );
 35
 36      List<CalculationModel> expectedResult = [expectedModel];
 37
 38      expect(
 39        CalculationHistoryService(sharedPreferences: mock).fetchAllEntries(),
 40        expectedResult
 41      );
 42    });
 43
 44    test('Properly fetches empty list if nothing has been stored yet', () async {
 45      String mockedJson = jsonEncode([]);
 46
 47      MockSharedPreferences mock = _getMockSharedPreferences(
 48        sharedPrefKey, mockedJson, false
 49      );
 50
 51      List<CalculationModel> expectedResult = [];
 52
 53      expect(
 54        CalculationHistoryService(sharedPreferences: mock).fetchAllEntries(),
 55        expectedResult
 56      );
 57    });
 58
 59    test('If there is corrupt JSON in the shared preferences, it is deleted and an empty list is returned', () async {
 60      MockSharedPreferences mock = _getMockSharedPreferences(
 61        sharedPrefKey, 'invalid JSON', true
 62      );
 63
 64      expect(
 65        CalculationHistoryService(sharedPreferences: mock).fetchAllEntries(),
 66        []
 67      );
 68
 69      verify(mock.getString(sharedPrefKey)).called(1);
 70      verify(mock.remove(sharedPrefKey)).called(1);
 71    });
 72
 73    test('Shared preferences are called once when adding new entry on an empty list', () async {
 74      CalculationModel inputModel = _getCalculationModelAddSample();
 75
 76      String mockedJson = jsonEncode([inputModel.toJson()]);
 77
 78      MockSharedPreferences mock = _getMockSharedPreferences(
 79        sharedPrefKey, mockedJson, false
 80      );
 81
 82      await CalculationHistoryService(sharedPreferences: mock).addEntry(
 83        inputModel
 84      );
 85
 86      verifyNever(mock.getString(sharedPrefKey));
 87      verify(mock.setString(sharedPrefKey, mockedJson)).called(1);
 88    });
 89
 90    test('Shared preferences are called once with the correct json string when adding new entry on an existing list', () async {
 91      CalculationModel inputModel = _getCalculationModelAddSample();
 92
 93      String mockedExistingJson = jsonEncode([inputModel.toJson()]);
 94
 95      MockSharedPreferences mock = _getMockSharedPreferences(
 96        sharedPrefKey, mockedExistingJson, true
 97      );
 98
 99      String mockedExpectedJson = jsonEncode([
100        inputModel.toJson(), _getCalculationModelSubtractSample().toJson()
101      ]);
102
103      await CalculationHistoryService(sharedPreferences: mock).addEntry(
104        _getCalculationModelSubtractSample()
105      );
106
107      verify(mock.getString(sharedPrefKey)).called(1);
108      verify(mock.setString(sharedPrefKey, mockedExpectedJson)).called(1);
109    });
110  });
111}
112
113MockSharedPreferences _getMockSharedPreferences(String sharedPrefKey, String mockedJson, bool containsKey) {
114  MockSharedPreferences mock = MockSharedPreferences();
115  when(mock.getString(sharedPrefKey))
116    .thenAnswer((realInvocation) => mockedJson);
117  when(mock.containsKey(sharedPrefKey))
118    .thenAnswer((realInvocation) => containsKey);
119  return mock;
120}
121
122CalculationModel _getCalculationModelAddSample() {
123  CalculationModel inputModel = CalculationModel(
124    firstOperand: 1,
125    operator: '+',
126    secondOperand: 1,
127    result: 2
128  );
129  return inputModel;
130}
131
132CalculationModel _getCalculationModelSubtractSample() {
133  CalculationModel inputModel = CalculationModel(
134    firstOperand: 10,
135    operator: '-',
136    secondOperand: 6,
137    result: 4
138  );
139  return inputModel;
140}

I will not go into depth of unit testing in this article. There will be an article in the future covering that topic. However, I will give a high-level explanation on what’s happening:

We turn the SharedPreferences class into a mock. That means, we create a “copy” of that class that does nothing by itself and can be controlled by us. That means: we can change the behavior of methods.

Why would we want that? Well, let’s say we have a Camera class that abstracts from the actual hardware calls. During a unit test, we want to verify that the initialization is called. This would always lead to an error because in the test environment, there is no camera available since everything is executed in a simulated environment. So instead of an actual camera initialization we do “nothing” in this method. Seems pointless but this way we can verify that the method was called without receiving an error.

Same situation happens here: we use shared preferences. We don’t want the tests to always access the actual shared preferences of the phone. We don’t want to mess with real stored data since we don’t want to test shared preferences itself (this is the responsibility of the shared_preferences package). Instead, we want it to have a controlled behavior so that we can say if the content of the shared preferences is x then do y.

A good example is the second test. We mock the SharedPreferences class in a way that it returns a list with one (serialized) calculation in a list when getString() is called. We can then verify that this is also the response the fetchAllEntries() method is giving.

Basically we always make assumptions on the called methods and the return values when the shared preferences are set up in a certain way.

Flutter BLoC  service failed unit test results
Unit tests with one fail

Right, there was this one case that only came up during implementation of the tests. Let’s fix the implementation to make it a green tick:

 1List<CalculationModel> fetchAllEntries() {
 2    List<CalculationModel> result = <CalculationModel>[];
 3
 4    if (!sharedPreferences.containsKey(_sharedPreferenceKey)) {
 5      return [];
 6    }
 7
 8    List<dynamic> history = [];
 9
10    try {
11      history = jsonDecode(
12          sharedPreferences.getString(_sharedPreferenceKey)
13      );
14    } on FormatException {
15      sharedPreferences.remove(_sharedPreferenceKey);
16    }
17
18    for (Map<String, dynamic> entry in history) {
19      result.add(
20        CalculationModel.fromJson(entry)
21      );
22    };
23
24    return result;
25  }

If we repeat the tests now, everything should be fine:

Flutter BLoC service unit test results
Our successful test results

The great thing about this is: no matter what parts of the application we continue to implement now we can be sure that if anything fails, it’s most likely not the service that causes this issue. This gives us a lot of confidence when finding causes of issues.

Events, states and BLoC

Now it’s time to actually call the service! For this, we need to add an event that gives the view layer the opportunity to communicate to the BLoC: “Hey, please give me the list of recent calculations”.

1class FetchHistory extends CalculationEvent {
2  @override
3  List<Object> get props =>  [];
4}

Easy! The resulting states can be the general purpose state we already have because the UI does not have to react differently on the different state types. If we had a “Loading” state, we would have to implement it separately because the UI would display a loading indicator only when this event occurs.

What we need to is to extend the current state by the history:

 1import 'package:equatable/equatable.dart';
 2import 'package:meta/meta.dart';
 3
 4import '../calculation_model.dart';
 5
 6abstract class CalculationState extends Equatable {
 7  final CalculationModel calculationModel;
 8  final List<CalculationModel> history;
 9
10  const CalculationState({
11    required this.calculationModel,
12    required this.history
13  });
14
15  @override
16  List<Object> get props => [calculationModel, history];
17}
18
19class CalculationInitial extends CalculationState {
20  CalculationInitial() : super(calculationModel: CalculationModel(), history: []);
21}
22
23class CalculationChanged extends CalculationState {
24  final CalculationModel calculationModel;
25  final List<CalculationModel> history;
26
27  const CalculationChanged({
28    required this.calculationModel,
29    required this.history
30  })
31    : super(calculationModel: calculationModel, history: history);
32
33  @override
34  List<Object> get props => [calculationModel, history];
35}

It’s just a second constructor argument that must not be null and is of type List<CalculationModel>.

Now in the BLoC, every time we have yielded a state so far, we now have to add history as an argument. For this purpose, we can just provide List.of(state.history) to clone the existing history like this:

1yield CalculationChanged(
2  calculationModel: resultModel,
3  history: List.of(state.history)
4);

There are two situations, though, where we must provide the actual history. The first is when the UI requests the history (initially) and the second one is after a calculation because the list has updated.

 1@override
 2  Stream<CalculationState> mapEventToState(
 3    CalculationEvent event,
 4  ) async* {
 5    ...
 6    if (event is FetchHistory) {
 7      yield CalculationChanged(
 8        calculationModel: state.calculationModel,
 9        history: calculationHistoryService.fetchAllEntries()
10      );
11    }
12    ...
13  }

This first case is fairly simple. If we receive the FetchHistory event, we return the existing model with the fetched history.

 1Stream<CalculationState> _mapCalculateResultToState(
 2    CalculateResult event,
 3  ) async* {
 4  ...
 5  CalculationModel newModel = CalculationInitial().calculationModel.copyWith(
 6    firstOperand: () => result
 7  );
 8
 9  yield CalculationChanged(
10    calculationModel: newModel,
11    history: List.of(state.history)
12  );
13
14  yield* _yieldHistoryStorageResult(model, newModel);
15}
16
17Stream<CalculationChanged> _yieldHistoryStorageResult(CalculationModel model, CalculationModel newModel) async* {
18  CalculationModel resultModel = model.copyWith(result: () => newModel.firstOperand);
19
20  if(await calculationHistoryService.addEntry(resultModel)) {
21    yield CalculationChanged(
22      calculationModel: newModel,
23      history: calculationHistoryService.fetchAllEntries()
24    );
25  }
26}

In the case of a finished calculation, we first yield the calculation result like before and then yield the history that is fetched. Now we make use of the Stream the BLoC is working with which enables us to yield multiple states after an action.

For the sake of completeness: if we had tests for our bloc, we would have to adapt all of them with the history and write ones for the FetchHistory event. I will skip this here and handle this topic in a separate article.

Creating the widget

We have put a lot of effort in the logic. Now let’s come to a part that can be actually seen: the widget that displays the history.

 1import 'package:basic_calculator/calculation_model.dart';
 2import 'package:flutter/material.dart';
 3
 4class CalculationHistoryContainer extends StatelessWidget{
 5  CalculationHistoryContainer({
 6    required this.calculations
 7  });
 8
 9  final List<CalculationModel> calculations;
10
11  @override
12  Widget build(BuildContext context) {
13    return Container(
14      height: 80,
15      margin: EdgeInsets.only(left: 24, right: 24, bottom: 24),
16      decoration: BoxDecoration(
17        color: Colors.white,
18        borderRadius: BorderRadius.all(Radius.circular(12)),
19        boxShadow: [
20          BoxShadow(
21            blurRadius: 2.0,
22            spreadRadius: 2.0,
23            color: Colors.black12
24          )
25        ]
26      ),
27      child: ListView(
28        padding: EdgeInsets.all(12),
29        children: [
30          for (var model in calculations)
31            Text(
32              '${model.firstOperand} ${model.operator} ${model.secondOperand} = ${model.result}',
33              textAlign: TextAlign.center
34            )
35        ],
36      )
37    );
38  }
39}

The widget hat displays the history has a list of CalculationModel as its only constructor argument. The visual representation of the widget itself is a shadowed container with a height of 80 wrapped around a list view that displays the calculations as text line by line.

Now it’s time to insert the CalculationHistoryContainer into the existing main screen. There’s nothing much we need to do because we separated view and logic.

 1@override
 2  void initState() {
 3    context.bloc<CalculationBloc>().add(FetchHistory());
 4    super.initState();
 5  }
 6
 7  @override
 8  Widget build(BuildContext context) {
 9    return BlocBuilder<CalculationBloc, CalculationState>(
10      builder: (context, CalculationState state) {
11        return Column(
12          children: [
13            ResultDisplay(
14              text: _getDisplayText(state.calculationModel),
15            ),
16            Row(
17              children: [
18                _getButton(text: '7', onTap: () => numberPressed(7)),
19                _getButton(text: '8', onTap: () => numberPressed(8)),
20                _getButton(text: '9', onTap: () => numberPressed(9)),
21                _getButton(text: 'x', onTap: () => operatorPressed('*'), backgroundColor: Color.fromRGBO(220, 220, 220, 1)),
22              ],
23            ),
24            Row(
25              children: [
26                _getButton(text: '4', onTap: () => numberPressed(4)),
27                _getButton(text: '5', onTap: () => numberPressed(5)),
28                _getButton(text: '6', onTap: () => numberPressed(6)),
29                _getButton(text: '/', onTap: () => operatorPressed('/'), backgroundColor: Color.fromRGBO(220, 220, 220, 1)),
30              ],
31            ),
32            Row(
33              children: [
34                _getButton(text: '1', onTap: () => numberPressed(1)),
35                _getButton(text: '2', onTap: () => numberPressed(2)),
36                _getButton(text: '3', onTap: () => numberPressed(3)),
37                _getButton(text: '+', onTap: () => operatorPressed('+'), backgroundColor: Color.fromRGBO(220, 220, 220, 1))
38              ],
39            ),
40            Row(
41              children: [
42                _getButton(text: '=', onTap: calculateResult, backgroundColor: Colors.orange, textColor: Colors.white),
43                _getButton(text: '0', onTap: () => numberPressed(0)),
44                _getButton(text: 'C', onTap: clear, backgroundColor: Color.fromRGBO(220, 220, 220, 1)),
45                _getButton(text: '-', onTap: () => operatorPressed('-'),backgroundColor: Color.fromRGBO(220, 220, 220, 1)),
46              ],
47            ),
48            Spacer(),
49            CalculationHistoryContainer(
50              calculations: state.history.reversed.toList()
51            )
52          ],
53        );
54      },
55    );
56  }

We need to pull the BlocBuilder up in the widget hierarchy because state changes affect the whole Column now. This is because the CalculationHistoryContainer is now part of the Column as well.

In order to make the history appear when the widget is rendered, we also need to add the FetchHistory() event to the BLoC on initState().

main.dart

We changed the constructor arguments of the CalculationBloc (by adding calculationHistoryService) but haven’t adapted the caller yet.

The problem: we can’t just do it like this:

 1Scaffold(
 2  body: BlocProvider(
 3    create: (context) {
 4      return CalculationBloc(
 5        calculationHistoryService: CalculationHistoryService(
 6          sharedPreferences: SharedPreferences.getInstance()
 7        )
 8      );
 9    },
10    child: Calculation(),
11  ),
12),

That’s because SharedPreferences.getInstance() is an async function. The build-method of our app widget, however, is not asynchronous because it must return a Widget and not a Future<Widget>. Another problem is that we don’t want to execute such things in a build-method of a widget because that might be executed several times. But in fact, one instantiation of SharedPreferences is sufficient for us. Solution: performing the instantiation at the very beginning.

 1main() async {
 2  WidgetsFlutterBinding.ensureInitialized();
 3  SharedPreferences sharedPreferences = await SharedPreferences.getInstance();
 4  runApp(
 5    CalculatorApp(
 6      sharedPreferences: sharedPreferences
 7    )
 8  );
 9}
10
11class CalculatorApp extends StatefulWidget {
12  CalculatorApp({
13    required this.sharedPreferences
14  });
15
16  final SharedPreferences sharedPreferences;
17
18  @override
19  _CalculatorAppState createState() => _CalculatorAppState(
20  );
21}
22
23class _CalculatorAppState extends State<CalculatorApp> {
24  @override
25  void initState() {
26    SystemChrome.setSystemUIOverlayStyle(
27      SystemUiOverlayStyle(
28        statusBarColor: Colors.transparent,
29      )
30    );
31
32    super.initState();
33  }
34
35  @override
36  Widget build(BuildContext context) {
37    return MaterialApp(
38      debugShowCheckedModeBanner: false,
39      title: 'Flutter basic calculator',
40      home: Scaffold(
41        body: BlocProvider(
42          create: (context) {
43            return CalculationBloc(
44              calculationHistoryService: CalculationHistoryService(
45                sharedPreferences: widget.sharedPreferences
46              )
47            );
48          },
49          child: Calculation(),
50        ),
51      ),
52    );
53  }
54}

By making the main() function async, we enable it to execute asynchronous functions in its body. Functions like SharedPreferences.getInstance(). This requires us to execute WidgetsFlutterBinding.ensureInitialized() beforehand. We extend the app widget by a constructor argument and put that in the widget tree.

Flutter calculator with history
The final calculator displaying its history

Conclusion

Calling a service from a BLoC is not that hard. If you want to make it right, you need to inject the service into the BLoC (and also inject every dependency into the service). This has the positive side-effect of it being testable. Since BLoCs work with Streams, we can yield the service’s return values after some other states so that no parts of the application needs to wait for the execution to finish.

GET FULL CODE

Comment this 🤌

You are replying to 's commentRemove reference