Ever imagined building a chat application like Facebook Messenger or WhatsApp? Typically, you would need to build a whole real-time infrastructure as a backend first, and then build your chat client for your users to start communicating. This could lead to a lot of time spent on development.
Building a Flutter chat application is surprisingly fast and easy thanks to our friends at Stream and their Stream Chat SDK, which provides us with all the functionality we need to hook up to a powerful chat API and start developing our app right away.
We'll learn how to build a location-sharing chat app with Stream's real-time infrastructure and our opinionated set of tools (like flutter_bloc) and best practices. So, let's build a very good chat app!
ChatRepository
First, we'll create a ChatRepository that will manage the chat domain. This repository will be created as a standalone package and will have the following dependencies:
location: ^4.3.0
stream_chat_flutter: ^2.0.0
To use the Stream Chat SDK, we will provide our ChatRepository with a StreamChatClient that will act as our API in charge of interacting with the Stream backend. Additionally, we'll have a dependency on Location to have access to the device's location services, which will allow us to handle permissions and retrieve the current location data.
class ChatRepository {
ChatRepository({
StreamChatClient? chatClient,
Location? location,
}) : _chatClient = chatClient ?? StreamChatClient('<PLACEHOLDER_API_KEY>'),
_location = location ?? Location();
final StreamChatClient _chatClient;
final Location _location;
}
StreamChatClient
We'll use our StreamChatClient to handle the connection and joining channels to chat.
The connect method must be the first to be called as it's the entry point for our user to be authenticated. It accepts extra information like the user's avatar, which can be provided as a Uri.
Now that our client has authenticated the user, we can access the currentUser via the client's state property. We will return the user id from this getUserId method for it to authenticate the user on the Stream Chat SDK widgets (you will see it in action later).
String getUserId() {
final user = _chatClient.state.currentUser;
if (user == null) {
throw StateError(
'could not retrieve user. did you forget to call connect()?',
);
}
return user.id;
}
Location
To share the user's location, we need to first retrieve it through our Location API. This is achieved by the getCurrentLocation method, which already handles asking for the location permission. The location is returned in the shape of a custom class called CoordinatePair.
Future<CoordinatePair> getCurrentLocation() async {
final serviceEnabled = await _location.serviceEnabled();
if (!serviceEnabled) {
final isEnabled = await _location.requestService();
if (!isEnabled) throw CurrentLocationFailure();
}
final permissionStatus = await _location.hasPermission();
if (permissionStatus == PermissionStatus.denied) {
final status = await _location.requestPermission();
if (status != PermissionStatus.granted) throw CurrentLocationFailure();
}
late final LocationData locationData;
try {
locationData = await _location.getLocation();
} catch (_) {
throw CurrentLocationFailure();
}
final latitude = locationData.latitude;
final longitude = locationData.longitude;
if (latitude == null || longitude == null) throw CurrentLocationFailure();
return CoordinatePair(latitude: latitude, longitude: longitude);
}
Chat UI
Now that we have our ChatRepository, it's time to create our second package: chat_ui. This package will contain our widgets that will be abstracting the Stream Chat SDK widgets for our customization.
ChannelAppBar
First, we will create a ChannelAppBar that will use ChannelHeader from Stream Chat under the hood. It looks like this:
class ChannelAppBar extends StatelessWidget with PreferredSizeWidget {
const ChannelAppBar({
Key? key,
required stream_chat_flutter.Channel channel,
}) : _channel = channel,
super(key: key);
final stream_chat_flutter.Channel _channel;
@override
Widget build(BuildContext context) {
final channel = context
.findAncestorStateOfType<stream_chat_flutter.StreamChannelState>();
if (channel != null) {
return const stream_chat_flutter.ChannelHeader();
}
return stream_chat_flutter.StreamChannel(
channel: _channel,
child: const stream_chat_flutter.ChannelHeader(),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
ChannelListView
To build our channel list, we will abstract the Stream Chat's ChannelListView with its own ChannelsBloc. That way, we can provide the ChannelsBloc to any channel we create.
We achieve this by specifying a function that we call channelBuilder, which is simply a callback with the context and the channel provided by Stream Chat's ChannelListView. This is also how we provide the channel that will be used on the next page to display the messages.
MessageListView
Now we want to display the channel's messages. So we will do some wrapping of Stream Chat's StreamChannel and MessageListView widgets. Stay with me on this one, it's kind of tricky.
First, we receive the corresponding channel from the ChannelListView we made earlier, which will help us to provide a channel for the StreamChannel widget to populate the messages list.
Since we want to add attachments such as location, we created a typedef called OnGenerateAttachements that is just a Map with a builder function. We populate the attachments of the messages based on what we get from the constructor.
At last, we create a MessageListView from the Stream Chat SDK and copy the attachments to its widget. We can build our StreamChannel widget with the channel and our recently created message list.
MessageInput
To finish our UI package with the attachment and channel lookup logic, we need the last widget: a message input to type and send a new message to the channel.
This widget is similar to MessageListView. It will wrap a MessageInput from the Stream Chat SDK. We will also add new functionality for actions, such as a share location button and the attachment thumbnail.
With that, we've finished our chat_ui package. 🎉
Building the app
Bootstrapping
As we mentioned at the beginning, this app is created using our Very Good CLI, which creates a project using the Very Good Core template.
In addition to what our CLI generates, we are going to create a new file called bootstrap.dart. This file will be in charge of initializing all of our dependencies and inject them into the app. This helps set up specific configurations before you run the app and allows us to initialize asynchronous dependencies. We set up the Stream Chat client by providing a chatApiKey. We then inject the Stream Chat SDK into the ChatRepository.
The App widget requires a widget builder and the ChatRepository that was provided during bootstrapping. We wrap the MaterialApp with a RepositoryProvider that injects the ChatRepository so we can access it from the subtree. Lastly, we'll add our home page and the ChannelListPage.
Channel List
At Very Good Ventures, we separate our project by feature. Our first feature will be the channel list. Here, we will obtain the userId from the ChatRepository by using a Cubit from the bloc package.
ChannelListState
class ChannelListState extends Equatable {
const ChannelListState({required this.userId});
final String userId;
@override
List<Object> get props => [userId];
}
As you can see, on the ChannelListView from chat_ui, we use the channelBuilder property to provide the channel to the MessageListPage. That new page will be responsible for displaying the list of messages from the specified channel.
Message List
MessageListState
The MessageListCubit will manage the selected channel, as well as the user's current location.
We will create the CurrentLocation class first:
enum CurrentLocationStatus { unavailable, available, pending }
class CurrentLocation extends Equatable {
const CurrentLocation({
this.latitude = 0.0,
this.longtitude = 0.0,
this.status = CurrentLocationStatus.pending,
});
final double latitude;
final double longtitude;
final CurrentLocationStatus status;
@override
List<Object> get props => [latitude, longtitude, status];
CurrentLocation copyWith({
double? latitude,
double? longtitude,
CurrentLocationStatus? status,
}) {
return CurrentLocation(
latitude: latitude ?? this.latitude,
longtitude: longtitude ?? this.longtitude,
status: status ?? this.status,
);
}
}
Our new Cubit will have a dependency on the channel from the UI layer and the ChatRepository. We will expose a locationRequested method to get the current location of the device.
MessageListView consumes the MessageListCubit by selecting the channel it contains and providing it to the custom MessageListView widget. This widget also uses a BlocListener and reacts to the MessageListCubit by either showing a SnackBar when location is not available or adding a location attachment when location is shared.
We use the MessageInputController from the chat_ui package for the send functionality.
MapThumbnailImage extracts the map thumbnail image for the shared location message. This will be added to the channel as an attachment message each time the user shares their location.
With these widgets, we can build a whole chat user interface to manage conversations (also called channels).
Testing
Testing is an essential part of a successful and well-built app for us at VGV. Tests make it easy to know which parts of our code work properly. When it comes to changing existing functionality, tests can tell us if everything is still working as expected. We also have a very good playlist about testing fundamentals by our teammate Jorge, so go check it out after reading this!
The chat application we just built has 100% code coverage, which means every part of it has at least one test written for it. We will go through the most notable ones so that you can take a look at how to write them.
There are three types of tests in our application: unit (isolated components), widget (UI components), and bloc (business logic components). We will go through an example of each one.
Unit testing ChatRepository
Unit testing aims to test the smallest pieces of code. Luckily Dart makes it a breeze to test these components with its test package.
When we write a test, we first need to mock the dependencies of the component we are testing, if it has any. In this case, we're testing if the ChatRepository connects the user correctly. Our repository has a dependency on a StreamChatClient for this method, so we need to mock it.
We create a Mock class using the mocktail package by extending the Mock class and implementing the class we want to mock (in this case, StreamChatClient). We can instantiate our chat client as a MockStreamChatClient and pass it to the ChatRepository.
Now, we can stub the behavior we want for this dependency. For the connection functionality, we want to return a fake value that won't be stubbed, so we can create a FakeOwnUser class to return when the connect user method is called:
class FakeOwnUser extends Fake implements OwnUser {}
We use the when method to stub a fake user when the method is called with any kind of argument. Then, we ensure the method completes normally by using the expect method with the completes matcher, as it is a void function and won't return anything.
Lastly, we use the verify method to make sure our dependency was called only once with the correct arguments.
Our presentation, or graphics components, should be tested too! Luckily, Flutter provides a complete framework to test this components easy with flutter_test.
Just as before, we must mock the dependencies of our widgets. For a ChannelListView, the dependencies are the StreamChatClient and the current Channel.
We first use the WidgetTester provided by the testWidgets function, which allows us to build and interact with widgets by rendering them using the pumpWidget method.
After we render the widget we're testing, we use the expect method with a finder provided by the flutter_test package. This is called find.byType which checks if one or more widgets of the specified type are found. We expect only one ChannelsBloc, so we use the findsOneWidget matcher.
We create a type finder for ChannelListView and use it to get the mentioned widget.
We create the expected filter for our ChannelListView and ensure it is rendered correctly.
Bloc testing MessageListCubit
We didn't forget about testing the business logic of the application. These components usually are harder to test, but don't worry: there's a package for that.
bloc_test is part of the bloc library, and helps us a lot to write clear, simple bloc tests to get faster to 100% code coverage.
As always, we start with dependencies:
class MockChannel extends Mock implements Channel {}
class MockChatRepository extends Mock implements ChatRepository {}
void main() {
group('MessageListCubit', () {
late Channel channel;
late ChatRepository chatRepository;
setUp(() {
channel = MockChannel();
chatRepository = MockChatRepository();
});
});
}
After that, we can write our bloc test. We will test the "happy path" of the locationRequested method.
A bloc test helps us build our bloc with its dependencies, act by adding an event (bloc) or calling a method (cubit) that would emit some state, and expect a list of states that are going to be emitted.
To learn more about bloc_test, check the package documentation.
Here, we stub our dependency and build the cubit with it. Then, call the method that will cause a new list of states to be emitted. And at last, specify the states we expect with their properties.
And there you go, a test for each layer of our app! 🧪
Wrap up
We finished building our chat application with location-sharing functionality! 🎉