Originally published on bendyworks.com.
The data for Birb will be stored in Firebase Cloud Firestore. When looking at patterns to handle getting and rendering data, I decided to go with the StreamBuilder
example from the cloud_firestore package documentation. I don't know how well pagination will work with this approach so that will be an experiment for another day.
The first change I'm making is turning the static List<int>
into a Stream
, and moving it further up the widget tree. I want the PostsList
widget to only care about rendering items from a Stream
, not how to create the Stream
itself. This has the bonus of making it easier to mock data in the tests.
final Stream<List<int>> _posts = Stream<List<int>>.fromIterable(
<List<int>>[
List<int>.generate(10, (int i) => i),
],
);
~~~{% endraw %}
This looks a little weird but it is basically generating a list of 10 items. That {% raw %}`List`{% endraw %} of 10 items is used as the first value in a new {% raw %}`Stream`{% endraw %}. From the subscription side of the {% raw %}`Stream`{% endraw %}, there will be a single event with data that is a {% raw %}`List`{% endraw %} of 10 items. I'm going with this pattern because Firestore will have snapshots with multiple documents.
The {% raw %}`PostsList` `build` method needs to be updated to consume the `Stream` and use a `StreamBuilder`:
~~~dart
@override
Widget build(BuildContext context) {
return StreamBuilder<List<int>>(
stream: posts,
builder: (BuildContext context, AsyncSnapshot<List<int>> snapshot) {
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
switch (snapshot.connectionState) {
case ConnectionState.waiting:
return const Text('Loading...');
default:
if (snapshot.data.isEmpty) {
return const NoContent();
}
return _itemList(snapshot.data);
}
},
);
}
~~~
The `StreamBuilder` takes a `Stream` and will then call the builder with an [`AsyncSnapshot`](https://docs.flutter.io/flutter/widgets/AsyncSnapshot-class.html). There are a couple of different states on this snapshot that need to be handled:
- The first is checking to see if there has been an error. If there has been, render some error text.
- Second, if the connection is waiting, show a loader while waiting for data to arrive.
- Third, if there is no data, render the `NoContent` widget.
- Finally if none of the previous cases are met, render the actual data.
Looking at the tests for `PostList`, all four of those scenarios are tested by creating different kinds of mock streams with the `_postsStream` helper.
~~~dart
Stream<List<int>> _postsStream(int count) {
return Stream<List<int>>.fromIterable(
<List<int>>[
List<int>.generate(count, (int i) => i),
],
);
}
~~~
Then I can test that the loading text is shown, followed a test that all the mocked items are rendered.
~~~dart
testWidgets('renders list of PostItems', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(MaterialApp(
home: PostsList(_postsStream(5)),
));
expect(find.text('Loading...'), findsOneWidget);
await tester.pump(Duration.zero);
expect(find.byType(PostItem), findsNWidgets(5));
});
~~~
To simulate the error case, I can throw an error on a `Future` and convert that to a stream.
~~~dart
testWidgets('renders NoContent widget', (WidgetTester tester) async {
// Build our app and trigger a frame.
await tester.pumpWidget(MaterialApp(
home: PostsList(Future<List<int>>.error('Bad Connection').asStream()),
));
await tester.pump(Duration.zero);
expect(find.text('Error: Bad Connection'), findsOneWidget);
});
~~~
One other minor change I made was to reduce the height of the cards so I could be sure the additional cards were rendering.
![List of short material cards](https://thepracticaldev.s3.amazonaws.com/i/7btcsxqqu4xif69xvp2r.png)
## Code changes
- [#25 Convert PostsList to use a StreamBuilder](https://github.com/abraham/birb/pull/25)