Building a Very Good Location Sharing Chat App with Flutter

How to build a chat app with location sharing functionality using Stream and Very Good Core

Open Source
September 29, 2021
updated on
October 18, 2021
and 
September 29, 2021
updated on
October 18, 2021
By 
Guest Contributor

This article is an extension of the tutorial "Add Location Sharing to a Messaging App Using Flutter" by Nash R. at Stream.

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.

Stream Chat SDK

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.


Future<void< connect({
  required String userId,
  required String token,
  Uri? avatarUri,
}) {
  final extraData = <String, String>{};
  if (avatarUri != null) {
    extraData['image'] = '$avatarUri';
  }
  return _chatClient.connectUser(
    User(id: userId, extraData: extraData),
    token,
  );
}

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.


class ChannelListView extends StatelessWidget {
  const ChannelListView({
    Key? key,
    required this.userId,
    required this.channelBuilder,
  }) : super(key: key);

  final String userId;

  final Widget Function(
    BuildContext context,
    stream_chat_flutter.Channel channel,
  ) channelBuilder;

  @override
  Widget build(BuildContext context) {
    return stream_chat_flutter.ChannelsBloc(
      child: stream_chat_flutter.ChannelListView(
        filter: stream_chat_flutter.Filter.in_('members', [userId]),
        onChannelTap: (channel, child) {
          Navigator.of(context).push(
            MaterialPageRoute(
              builder: (_) => channelBuilder(context, channel),
            ),
          );
        },
      ),
    );
  }
}

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.


class MessageListView extends StatelessWidget {
  const MessageListView({
    Key? key,
    required stream_chat_flutter.Channel channel,
    OnGenerateAttachements? onGenerateAttachements,
  })  : _channel = channel,
        _onGenerateAttachements = onGenerateAttachements ?? const {},
        super(key: key);

  final stream_chat_flutter.Channel _channel;
  final OnGenerateAttachements _onGenerateAttachements;

  @override
  Widget build(BuildContext context) {
    final channel = context
        .findAncestorStateOfType<stream_chat_flutter.StreamChannelState>();

    final customAttachmentBuilder = <String, _CustomAttachementBuilder>{};
    for (final entry in _onGenerateAttachements.entries) {
      customAttachmentBuilder[entry.key] = (context, details, attachments) {
        return entry.value(context, details);
      };
    }

    final messageListView = stream_chat_flutter.MessageListView(
      messageBuilder: (context, details, messages, widget) {
        return widget.copyWith(
          customAttachmentBuilders: customAttachmentBuilder,
        );
      },
    );

    if (channel != null) return messageListView;

    return stream_chat_flutter.StreamChannel(
      channel: _channel,
      child: messageListView,
    );
  }
}

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.


class MessageInput extends StatefulWidget {
  const MessageInput({
    Key? key,
    required stream_chat_flutter.Channel channel,
    OnGenerateAttachementThumbnails? onGenerateAttachementThumbnails,
    List<Widget>? actions,
    MessageInputController? controller,
  })  : _channel = channel,
        _onGenerateAttachementThumbnails =
            onGenerateAttachementThumbnails ?? const {},
        _actions = actions ?? const [],
        _controller = controller,
        super(key: key);

  final stream_chat_flutter.Channel _channel;
  final OnGenerateAttachementThumbnails _onGenerateAttachementThumbnails;
  final List<Widget> _actions;
  final MessageInputController? _controller;

  @override
  _MessageInputState createState() => _MessageInputState();
}

class _MessageInputState extends State<MessageInput> {
  final _messageInputKey = GlobalKey<stream_chat_flutter.MessageInputState>();

  @override
  void initState() {
    super.initState();
    widget._controller?._key = _messageInputKey;
  }

  @override
  Widget build(BuildContext context) {
    final channel = context
        .findAncestorStateOfType<stream_chat_flutter.StreamChannelState>();
    final messageInput = stream_chat_flutter.MessageInput(
      key: _messageInputKey,
      actions: widget._actions,
      attachmentThumbnailBuilders: widget._onGenerateAttachementThumbnails,
    );

    if (channel != null) return messageInput;

    return stream_chat_flutter.StreamChannel(
      channel: widget._channel,
      child: messageInput,
    );
  }
}

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.


typedef AppBuilder = FutureOr<Widget> Function(
  Widget Function(Widget) builder,
  ChatRepository chatRepository,
);

Future<void> bootstrap({required AppBuilder builder}) async {
  Bloc.observer = AppBlocObserver();
  FlutterError.onError = (details) {
    log(details.exceptionAsString(), stackTrace: details.stack);
  };

  const chatApiKey = String.fromEnvironment('CHAT_API_KEY');
  if (chatApiKey.isEmpty) {
    throw StateError('missing environment variable <CHAT_API_KEY>');
  }

  final chatClient = StreamChatClient(chatApiKey, logLevel: Level.OFF);
  final chatRepository = ChatRepository(chatClient: chatClient);

  const chatToken = String.fromEnvironment('CHAT_TOKEN');
  if (chatToken.isEmpty) {
    throw StateError('missing environment variable <CHAT_TOKEN>');
  }

  const userId = '<YOUR_STREAM_CHAT_USER_ID>';
  final avatarUri = Uri.https(
    'getstream.imgix.net',
    'images/random_svg/FS.png',
  );
  await chatRepository.connect(
    userId: userId,
    token: chatToken,
    avatarUri: avatarUri,
  );

  await runZonedGuarded(
    () async => runApp(await builder(
      (child) => chat_ui.StreamChat(
        client: chatClient,
        child: child,
      ),
      chatRepository,
    )),
    (error, stackTrace) => log(error.toString(), stackTrace: stackTrace),
  );
}

App Widget


class App extends StatelessWidget {
  const App({
    Key? key,
    required Widget Function(Widget) builder,
    required ChatRepository chatRepository,
  })  : _builder = builder,
        _chatRepository = chatRepository,
        super(key: key);

  final Widget Function(Widget) _builder;
  final ChatRepository _chatRepository;

  @override
  Widget build(BuildContext context) {
    return RepositoryProvider.value(
      value: _chatRepository,
      child: MaterialApp(
        localizationsDelegates: const [
          AppLocalizations.delegate,
          GlobalMaterialLocalizations.delegate,
        ],
        supportedLocales: AppLocalizations.supportedLocales,
        home: const ChannelListPage(),
        builder: (_, child) => _builder(child!),
      ),
    );
  }
}

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];
}

ChannelListCubit


class ChannelListCubit extends Cubit<ChannelListState> {
  ChannelListCubit({
    required ChatRepository chatRepository,
  }) : super(ChannelListState(userId: chatRepository.getUserId()));
}

This ChannelListCubit will maintain the userId as part of its state, which we can provide to other widgets from the chat_ui package.

ChannelListPage

ChannelListPage is composed of two widgets:

  • ChannelListPage handles the page's general structure and injects the ChannelListCubit into the widget tree.

class ChannelListPage extends StatelessWidget {
  const ChannelListPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final l10n = context.l10n;
    return Scaffold(
      appBar: AppBar(title: Text(l10n.channelListAppBarTitle)),
      body: BlocProvider(
        create: (context) => ChannelListCubit(
          chatRepository: context.read<ChatRepository>(),
        ),
        child: const ChannelListView(),
      ),
    );
  }
}
  • ChannelListView accesses the userId from the ChannelListCubit and provides it to the custom ChannelListView widget.

class ChannelListView extends StatelessWidget {
  const ChannelListView({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final userId = context.select(
      (ChannelListCubit cubit) => cubit.state.userId,
    );
    return chat_ui.ChannelListView(
      userId: userId,
      channelBuilder: (_, channel) => MessageListPage(channel: channel),
    );
  }
}

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,
    );
  }
}

Then, we can add it to the state:


class MessageListState extends Equatable {
  const MessageListState({
    required this.channel,
    this.location = const CurrentLocation(),
  });

  final Channel channel;
  final CurrentLocation location;

  @override
  List<Object> get props => [channel, location];

  MessageListState copyWith({Channel? channel, CurrentLocation? location}) {
    return MessageListState(
      channel: channel ?? this.channel,
      location: location ?? this.location,
    );
  }
}

MessageListCubit

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.


class MessageListCubit extends Cubit<MessageListState> {
  MessageListCubit({
    required Channel channel,
    required ChatRepository chatRepository,
  })  : _chatRepository = chatRepository,
        super(MessageListState(channel: channel));

  final ChatRepository _chatRepository;

  void locationRequested() async {
    emit(
      state.copyWith(
        location: state.location.copyWith(
          status: CurrentLocationStatus.pending,
        ),
      ),
    );

    try {
      final location = await _chatRepository.getCurrentLocation();
      emit(
        state.copyWith(
          location: state.location.copyWith(
            status: CurrentLocationStatus.available,
            latitude: location.latitude,
            longtitude: location.longitude,
          ),
        ),
      );
    } catch (_) {
      emit(
        state.copyWith(
          location: state.location.copyWith(
            status: CurrentLocationStatus.unavailable,
          ),
        ),
      );
    }
  }
}

MessageListPage

The ChannelListPage is composed of a few widgets:

  • MessageListPage handles the page's general structure and injects the MessageListCubit into the widget tree.

class MessageListPage extends StatelessWidget {
  const MessageListPage({Key? key, required Channel channel})
      : _channel = channel,
        super(key: key);

  final Channel _channel;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: ChannelAppBar(channel: _channel),
      body: BlocProvider(
        create: (context) => MessageListCubit(
          channel: _channel,
          chatRepository: context.read<ChatRepository>(),
        ),
        child: const MessageListView(),
      ),
    );
  }
}
  • 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.

class MessageListView extends StatefulWidget {
  const MessageListView({Key? key}) : super(key: key);

  @override
  _MessageListViewState createState() => _MessageListViewState();
}

class _MessageListViewState extends State<MessageListView> {
  late final chat_ui.MessageInputController _controller;

  @override
  void initState() {
    super.initState();
    _controller = chat_ui.MessageInputController();
  }

  @override
  Widget build(BuildContext context) {
    final channel = context.select(
      (MessageListCubit cubit) => cubit.state.channel,
    );
    return BlocListener<MessageListCubit, MessageListState>(
      listenWhen: (prev, curr) => prev.location.status != curr.location.status,
      listener: (context, state) {
        if (state.location.status == CurrentLocationStatus.unavailable) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(
              content: Text(
                '''We can't access your location at this time. Did you allow location access?''',
              ),
            ),
          );
        }

        if (state.location.status == CurrentLocationStatus.available) {
          _controller.addAttachment(
            Attachment(
              type: 'location',
              uploadState: const UploadState.success(),
              extraData: {
                'latitude': state.location.latitude,
                'longitude': state.location.longtitude,
              },
            ),
          );
        }
      },
      child: Column(
        children: [
          Expanded(child: chat_ui.MessageListView(channel: channel)),
          chat_ui.MessageInput(
            controller: _controller,
            channel: channel,
            onGenerateAttachementThumbnails: {
              'location': (context, attachment) {
                return MapThumbnailImage(
                  latitude: attachment.extraData['latitude'] as double,
                  longitude: attachment.extraData['longitude'] as double,
                );
              },
            },
            actions: [
              IconButton(
                icon: const Icon(Icons.location_history),
                onPressed: () {
                  context.read<MessageListCubit>().locationRequested();
                },
              ),
            ],
          ),
        ],
      ),
    );
  }
}
  • 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.

class MapThumbnailImage extends StatelessWidget {
  const MapThumbnailImage({
    Key? key,
    required this.latitude,
    required this.longitude,
  }) : super(key: key);

  final double latitude;
  final double longitude;

  Uri get _thumbnailUri {
    return Uri(
      scheme: 'https',
      host: 'maps.googleapis.com',
      port: 443,
      path: '/maps/api/staticmap',
      queryParameters: {
        'center': '$latitude,$longitude',
        'zoom': '18',
        'size': '700x500',
        'maptype': 'roadmap',
        'key': '<YOUR-GOOGLE-MAPS-KEY>',
        'markers': 'color:red|$latitude,$longitude'
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Image.network(
      '$_thumbnailUri',
      height: 300.0,
      width: 600.0,
      fit: BoxFit.fill,
    );
  }
}

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.


import 'package:mocktail/mocktail.dart';
import 'package:stream_chat/stream_chat.dart';

class MockStreamChatClient extends Mock implements StreamChatClient {}

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.


group('ChatRepository', () {
  late StreamChatClient chatClient;
  late ChatRepository chatRepository;

  setUpAll(() {
    registerFallbackValue(FakeUser());
  });

  setUp(() {
    chatClient = MockStreamChatClient();
    chatRepository = ChatRepository(chatClient: chatClient);
  });
}

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.


group('connect', () {
  const userId = 'test-user-id';
  const token = 'test-token';
  final avatarUri = Uri.https('test.com', 'profile/pic.png');

  test('connects with the provided userId, token, and avatarUri', () {
    when(
      () => chatClient.connectUser(any(), any()),
    ).thenAnswer((_) async => FakeOwnUser());

    expect(
      chatRepository.connect(
        userId: userId,
        token: token,
        avatarUri: avatarUri,
      ),
      completes,
    );

    verify(
      () => chatClient.connectUser(
        User(id: userId, extraData: {'image': '$avatarUri'}),
        token,
      ),
    ).called(1);
  });
});

Widget Testing ChannelListView

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.


import 'package:mocktail/mocktail.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart' as stream_chat_flutter;

class MockStreamChatClient extends Mock implements stream_chat_flutter.StreamChatClient {}

class MockChannel extends Mock implements stream_chat_flutter.Channel {}

void main() {
  group('ChannelListView', () {
    late stream_chat_flutter.StreamChatClient client;

    setUp(() {
      client = MockStreamChatClient();
      when(
        () => client.on(any(), any(), any(), any()),
      ).thenAnswer((_) => const Stream.empty());
      when(
        () => client.wsConnectionStatus,
      ).thenReturn(stream_chat_flutter.ConnectionStatus.connected);
    });
  });
}

After we mocked and stubbed our dependencies, we can test our widgets.


testWidgets(
  'renders ChannelListView with correct configuration',
  (tester) async {
    const userId = 'test-user-id';
    await tester.pumpWidget(
      MaterialApp(
        home: stream_chat_flutter.StreamChat(
          client: client,
          child: Material(
            child: ChannelListView(
              userId: userId,
              channelBuilder: (context, channel) => const SizedBox(),
            ),
          ),
        ),
      ),
    );
    expect(find.byType(stream_chat_flutter.ChannelsBloc), findsOneWidget);
    final channelsListViewFinder =
        find.byType(stream_chat_flutter.ChannelListView);
    final channelsListView = tester
        .widget<stream_chat_flutter.ChannelListView>(channelsListViewFinder);
    final expectedFilter = stream_chat_flutter.Filter.in_(
      'members',
      const [userId],
    );
    expect(channelsListView.filter, equals(expectedFilter));
  },
);

A couple of things to note here:

  • 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.


const latitude = 42.0;
const longitude = 13.37;
const coordinatePair = CoordinatePair(
  latitude: latitude,
  longitude: longitude,
);
blocTest<MessageListCubit, MessageListState>(
  'emits [pending, available] when location is available',
  build: () {
    when(
      () => chatRepository.getCurrentLocation(),
    ).thenAnswer((_) async => coordinatePair);
    return MessageListCubit(
      channel: channel,
      chatRepository: chatRepository,
    );
  },
  act: (cubit) => cubit.locationRequested(),
  expect: () => [
    MessageListState(channel: channel),
    MessageListState(
      channel: channel,
      location: const CurrentLocation(
        latitude: latitude,
        longtitude: longitude,
        status: CurrentLocationStatus.available,
      ),
    ),
  ],
);

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! 🎉

This example was created using the Stream Chat SDK and Very Good CLI with our opinionated practices. You can take a look at the full source code here.

Hope you enjoyed going through this tutorial and we can't wait to see what you build next with these two packages together.

Additional Contributors

More Stories