Build a Flutter Social Chat with Stream: Bloc and Domain Driven Design

11 min read
alper_efe_sahin
alper_efe_sahin
Published January 24, 2023

If you want to build an application with the cross-platform framework Flutter, you are in the right place. After gaining some Flutter experience, you may need to build more complex applications. Generally, If you do not consider UI/UX sections, it’s hard to understand and apply communication sections. For instance, If you use the BLoC pattern for managing state or WebSockets directly, you need to figure out their communication before applying them.

This article will cover a lot of high-quality packages and tools to create a chat application, including:

  • Two different, popular services (Firebase & Stream)
  • Two different presentations of the Stream usage (Prepared UI, which is coming from the Stream Package and Manuel UI that's combined our UI design with the core package)
  • BLoC Pattern for the State Management
  • Local Storage with Hydrated Bloc
  • DDD (Domain Driven Design) Architecture • Dependency Injection with Get_it and Injectable packages
  • Internet Connection Checker: Connectivity
  • Localization from official Flutter docs

This article does not consider the widgets, and the things that are not related to Stream, assuming you have knowledge about Flutter, and a bit of Dart. The article supposes you want to build more complex applications with high-quality packages.

The sources for the things that are not related to Stream (which we will not cover in this article, but are necessary for the full app) for you include:

All Packages:

If you would like to follow along, feel free to create a Stream free account and clone the repositiory on Github.

Folder Architecture

First, let’s look at the folder architecture of the project.

Flutter social chat folder architecture

We’ll use DDD, as I mentioned. There are four different folders, each folder has distinct purposes.

  1. Application: For the business logic
  2. Domain: Models, Interfaces, Failures etc.
  3. Infrastructure: Implementation of the services, helper classes, core things like DI (dependency Injection)
  4. Presentation: All related UI parts of the application
    firebase_options.dart file is related to the Firebase, secrets.dart file is related to Stream, and finally injection.dartand injection.config.dart file is related to the DI.
    For more information about the DDD, you can check the following link: https://medium.com/itnext/how-to-architect-a-production-level-app-in-flutter-phone-number-sign-in-263628e1872c

After explaining the folder architecture, now we can start to build our application. Let’s start with the domain folder.

Domain Layer

Flutter social chat Domain Layer

The subfolders are all our features in this project. All of them have related folder names, like auth camera, etc., and they represent the features.

For instance, we have the following homepage, and this homepage actually has three main routes: Chats, Camera, and Profile.

The homepage seems quite nice, but to arrive at the homepage, first we need to complete the auth section of the application. You can use whatever you want, but we’ll use the Firebase service and its functions for the AuthenticationSection. Then, we’ll connect with the Stream service.

For the auth section, as I said, you should follow the above links to complete it. The phone number sign-in project is exactly the same as ours.

After completing the auth section, we can move into the chat section.

Chat Section

Chat folder section

In the beginning, you need to know that filename.freezed.dart or filename.g.dart files are all generated files that are coming from the freezed and json_serializable packages.

part 'chat_failure.freezed.dart';

@freezed
class ChatFailure with _$ChatFailure {
  const factory ChatFailure.serverError() = ServerError;
}

In the chat_failure.dart file, we have related failures/errors for the chat. We use freezed package for this, and after creating the above failures, we need to run the magical build runner code to create the freezed file for us:

bash
Build runner’s code: flutter packages pub run build_runner build --delete-conflicting-outputs

If you paste it to the terminal and run, you’ll see the red lines of the chat_failure.dart file will disappear.
https://gist.github.com/alperefesahin/5c0e3fed1aaa23ea489380ec956aba89

Now we create our user model that is different than the auth one. So, since the services are different (handling this situation is quite important) we need to handle them separately. After creating and handling the chat service, we can use them efficiently. For this article, since we are not cover all the properties of the Stream, we just consider the three important properties: User's Created Time, User's Role, and User's Ban Status. If you want to add more user properties such as last seen, team count, etc., you can add of course.

For the fromJson method, we can say it’s for local storage. Since we use HydratedBloc for the local storage, and it needs the related functions, we need to fromJson method of this model.

abstract class IChatService {
  Stream<ChatUserModel> get chatAuthStateChanges;

  Stream<List<Channel>> get channelsThatTheUserIsIncluded;

  Future<void> disconnectUser();

  Future<void> connectTheCurrentUser();

  Future<void> createNewChannel({
    required List<String> listOfMemberIDs,
    required String channelName,
    required String channelImageUrl,
  });

We also have the interface for the chat. With this, we can listen to authentication changes of the chat service, we can follow the channels (chats) of the current/logged-in user, can connect and disconnect, can create a new chat (channel), and can send an attachment (here, we actually use it for the sending photo, so the attachment is a photo).

It’s important to determine functions so that we’ve started with the domain folder.

So, we covered and completed the chat&auth sections for the domain folder. We have three more folders: camera, microphone, and connectivity sections. They are not complicated and almost all of them have the same logic. You can find their interfaces, implementations, and more by following the links.

With the above sections and links, we have completed the Domain Layer successfully.

Infrastructure Layer

Infra setup, Flutter social chat

In the chat folder, we will consider the implementation of the chat service, and in the core folder, we’ll set up the dependency injection modules for the 3rd party packages and we’ll create our helper classes.

Let’s start with the core folder since we’ll use it in the chat folder.

Infrastructure/Core

So, first let me explain the injectable_module.dart file.

@module
abstract class InjectableModule {
  @singleton
  Connectivity get connectivity => Connectivity();

  @singleton
  AppRouter get appRouter => AppRouter();

  @lazySingleton
  FirebaseAuth get firebaseAuth => FirebaseAuth.instance;

  @lazySingleton
  FirebaseFirestore get firestore => FirebaseFirestore.instance;

  @lazySingleton
  FirebaseStorage get firebaseStorage => FirebaseStorage.instance;

  @singleton
  StreamChatClient get streamChatClient => StreamChatClient(getstreamApiKey, logLevel: Level.INFO);
}

Injectable module file is completely for the injectable package. If you do not want to use injectable package, you can skip this part, but I strongly recommend it to get rid of the spaghetti codes.

For each getter, we annotate a tag such as singleton or lazySingleton, etc. How to decide? Here’s what I suggest:

  • Singleton: If you want to create just one instance from this class, you need to annotate it with @singleton. It is not lazy, so it will create immediately when you run the app.

  • LazySingleton: If you want to create just one instance from this class, and also If you want to use this class when the app needs it, then you need to annotate it with @lazySingleton.

  • Injectable: If you want to create an instance from this class every time the class is executed, then you need to annotate it with @injectable.

So, here we annotate the streamChatClient as singleton since our streamChatClient will never change in the application, and we do not want to use it as lazySingleton since we want it immediately (it depends on the usage of the getter, If you want to use it later on, then you can annotate it with lazySingleton).

Next, again in the core folder, we have the following helper file: Stream_helpers.dart

extension GetstreamUserDomainX on OwnUser {
  ChatUserModel toDomain() {
    return ChatUserModel(
      createdAt: formatDate(createdAt, [yyyy, '-', mm, '-', dd]),
      userRole: role?.toUpperCase() ?? "?",
      isUserBanned: banned,
    );
  }
}

Right now we are creating an extension for the OwnUser class, and our method’s name is toDomain. We return our model that is ChatUserModel, after filling it with the data that are coming from the Stream Service. You can add more helper classes or extensions if you need them.

Now time to complete the implementation of the chat service.

Implementation of the Chat Service

In the Stream_chat_service.dart file, there are lots of functions that come from the interface of the chat service. So, let’s explain them step by step.

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
  @override
  Stream<ChatUserModel> get chatAuthStateChanges {
    return streamChatClient.state.currentUserStream.map(
      (OwnUser? user) {
        if (user == null) {
          return ChatUserModel.empty();
        } else {
          return user.toDomain();
        }
      },
    );
  }

Here we have a chatAuthStateChanges getter that has the Stream<ChatUserModel> type. Here we are listening to the current user. If the user that is coming from the currentUserStream is null, then we are returning the empty user. Else, we can understand that the user is not null, so the user is logged in successfully. Because of that, we can return the user that is coming from the currentUserStream. But we do not want to return it normally since we do not use all the properties of the user. Here we use the toDomain function that is created by us recently.

  @override
  Future<void> disconnectUser() async {
    await streamChatClient.disconnectUser();
  }

  @override
  Future<void> connectTheCurrentUser() async {
    final signedInUserOption = await _firebaseAuth.getSignedInUser();

    final signedInUser = signedInUserOption.fold(
      () => throw Exception("Not authanticated"),
      (user) => user,
    );

    await streamChatClient.connectUser(

For the connection, we have to use some tricks.

Firstly, we need to get the signed-in user from the Firebase (I preferred to use functional programming, but you do not have to use of course, so there is a fold method, do not confuse). The connection will start after we signed in to the Firebase. So, If we do not sign in successfully, it means that the connection to the service will never start. So, we are safe.

After we get the signed user, then we can reach the user’s parameters like Id, name, etc.

  @override
  Stream<List<Channel>> get channelsThatTheUserIsIncluded {
    return streamChatClient
        .queryChannels(
      filter: Filter.in_(
        'members',
        [streamChatClient.state.currentUser!.id],
      ),
    )
        .map((listOfChannels) {
      return listOfChannels;
    });
  }

When we want to capture and send the photo using our cameras, after the capturing process, we’ll see the list of users. It channels that the current user exists there. So, thanks to Stream, we are not handling all the things. There is a parameter named filter. Using the filter, we can filter the channels.

  @override
  Future<void> createNewChannel({
    required List<String> listOfMemberIDs,
    required String channelName,
    required String channelImageUrl,
  }) async {
    final randomId = const Uuid().v1();

    await streamChatClient.createChannel(
      "messaging",
      channelId: randomId,
      channelData: {
        "members": listOfMemberIDs,
        "name": channelName,
        "image": channelImageUrl,
      },
    );
  }

To create a new channel, you need to specify the type of the channel, channelId, and channelData.

Also, there is a field that name is randomId. It’s there to create an id for our channels randomly. Because of that, we use the uuid package.

  @override
  Future<void> sendPhotoAsMessageToTheSelectedUser({
    required String channelId,
    required int sizeOfTheTakenPhoto,
    required String pathOfTheTakenPhoto,
  }) async {
    final randomMessageId = const Uuid().v1();

    final signedInUserOption = await _firebaseAuth.getSignedInUser();
    final signedInUser = signedInUserOption.fold(
      () => throw Exception("Not authanticated"),
      (user) => user,
    );
    final user = User(id: signedInUser.id);

Finally, the last function of our service is sending a photo (or an attachment). Firstly, we get the signed-in user as before. Using the signed user’s information, we create a field that name is the user.

Next, we need to send the image (or an attachment) to the service, and after the sending image, we want to send it to the selected user. To do that, we use the .then method to get the sent image URL. After that, we can send the message to the user that we selected. At this time, we see the CustomProgressIndicator widget for UX. You can check the presentation folder for this.

We successfully completed the implementation section. After the chat-related state management part, we can take a breath.

Application Layer

Application folder structure

Here we manage the application state. As I mentioned above, we’ll skip the folders except for the chat one. One important thing is, will be in the auth folder. I mentioned that first, we need to connect the Firebase and complete the auth. After that, we can connect to the Stream Service. So, after we signed in to the Firebase, we need to connect Stream Service.

Authentication Cubit

In the auth_cubit.dart file, when we signed in, we need to call the connectTheCurrentUser function that is coming from the chat service. Likewise, in the sign-out section, we also need to disconnect the user from the chat service.

  Future<void> signOut() async {
    await _authService.signOut();
    await _chatService.disconnectUser();
  }

  Future<void> _listenAuthStateChangesStream(AuthUserModel authUser) async {
    emit(
      state.copyWith(
        isInProgress: true,
        authUser: authUser,
        isUserCheckedFromAuthService: true,
      ),
    );

    if (state.isLoggedIn) {
      await _chatService.connectTheCurrentUser();

      emit(state.copyWith(authUser: authUser, isInProgress: false));
    }
  }

Next, in the _listenAuthStateChangesStream function, we listen to the Firebase Auth. So, If the user logged in, then we need to connect the user to the chat service. It’s the only thing that we need to do for the auth folder.

Management of the Chat

Chat management folder structure

In the chat folder, there are two other subfolders. chat_setup folder is for the core things like the auth one. chat_management folder is for other things, such as sending photos, creating a channel, etc.

First, let’s start with the chat_setup folder.

part of 'chat_setup_cubit.dart';

@freezed
class ChatSetupState with _$ChatSetupState {
  const factory ChatSetupState({
    required ChatUserModel chatUser,
    required ConnectionStatus webSocketConnectionStatus,
    required bool isUserCheckedFromChatService,
  }) = _ChatSetupState;

  const ChatSetupState._();

  factory ChatSetupState.empty() => ChatSetupState(
        chatUser: ChatUserModel.empty(),
        webSocketConnectionStatus: ConnectionStatus.disconnected,

Here we have ChatUserModel, which is coming from the domain folder and other necessary states. If you do not want to use webSocketConnectionStatus, you do not have to create it. I just created it for you to understand explicitly.

Also, we have one getter, which is a bool. So using this getter, we can check if the chat user is connected or not.

part 'chat_setup_state.dart';
part 'chat_setup_cubit.freezed.dart';

@lazySingleton
class ChatSetupCubit extends HydratedCubit<ChatSetupState> {
  late StreamSubscription<ChatUserModel>? _chatUserSubscription;

  late final IChatService _chatService;

  ChatSetupCubit() : super(ChatSetupState.empty()) {
    _chatService = getIt<IChatService>();

    _chatUserSubscription =
        _chatService.chatAuthStateChanges.listen(_listenChatUserAuthStateChangesStream);
  }

After we initialize our chat service, we also see one StreamSubscription. It’s for listening to our chat service authentication. This cubit file is the same as auth one. The fromJson and toJson functions are for the HyratedCubit (local storage).

part of 'chat_management_cubit.dart';

@freezed
class ChatManagementState with _$ChatManagementState {
  const factory ChatManagementState({
    required bool isInProgress,
    required bool isChannelNameValid,
    required bool isChannelCreated,
    required bool isCapturedPhotoSent,
    required String channelName,
    required int userIndex,
    required Set<String> listOfSelectedUserIDs,
    required Set<User> listOfSelectedUsers,
    required List<Channel> currentUserChannels,
  }) = _ChatManagementState;

The chat management section is a little bit challenging. We keep these states for our purposes. For example, If you do not want to use the camera feature, then you do not have to keep the current user channels (chats). It’s completely up to you.

Since the chat_management_cubit.dart file has a lot of functions, and let me explain them step by step:

First, let’s start with the cake ones, which are easy to understand.

  @override
  Future<void> close() async {
    await _currentUserChannelsSubscription?.cancel();
    super.close();
  }

  void reset() {
    emit(
      state.copyWith(
        isInProgress: false,
        isChannelCreated: false,
        isCapturedPhotoSent: false,
        listOfSelectedUsers: {},
        listOfSelectedUserIDs: {},
        channelName: "",

The all above functions are related to doing one job. For example, resetting the state, closing the subscription, changing the channel name, etc.

  Future<void> sendCapturedPhotoToSelectedUsers({
    required String pathOfTheTakenPhoto,
    required int sizeOfTheTakenPhoto,
  }) async {
    if (state.isInProgress) {
      return;
    }

    emit(state.copyWith(isInProgress: true));

    final channelId = state.currentUserChannels[state.userIndex].id;

    // For showing the progress indicator, and well UX.
    await Future.delayed(const Duration(seconds: 1));

Here we have a photo that is taken by us, and we want to send the selected user. So, we get the necessary things (path and size) and we use the function of the chat service.

Future<void> createNewChannel({
    required bool isCreateNewChatPageForCreatingGroup,
  }) async {
    if (state.isInProgress) {
      return;
    }

    String channelImageUrl = "";
    String channelName = state.channelName;
    final listOfMemberIDs = {...state.listOfSelectedUserIDs};

    final currentUserId = _authCubit.state.authUser.id;
    listOfMemberIDs.add(currentUserId);

    if (isCreateNewChatPageForCreatingGroup) {

Creating a channel manually is a little bit challenging since we want to set the distinct things like the selected user’s photo, name, etc.

Above, you see there is a field whose name is isCreateNewChatPageForCreatingGroup. It’s there to make a decision, are we want to create a group or a one-to-one chat. It separates the situation and behaves according to this. If the channel members count is two, we understand that it’s exactly a one-to-one chat, and if it’s more than two, then we understand that it is a group chat.

  void selectUserWhenCreatingAGroup({
    required User user,
    required bool isCreateNewChatPageForCreatingGroup,
  }) {
    final listOfSelectedUserIDs = {...state.listOfSelectedUserIDs};
    final listOfSelectedUsers = {...state.listOfSelectedUsers};

    if (!isCreateNewChatPageForCreatingGroup) {
      if (listOfSelectedUserIDs.isEmpty) {
        listOfSelectedUserIDs.add(user.id);
        listOfSelectedUsers.add(user);
      }
      emit(
        state.copyWith(
          listOfSelectedUserIDs: listOfSelectedUserIDs,

In this file, we both update our selected user and selected user ids. We do not use user ids directly since we’ll use them separately on UI.

  void selectUserToSendCapturedPhoto({
    required User user,
    required int userIndex,
  }) {
    final listOfSelectedUserIDs = {...state.listOfSelectedUserIDs};

    if (listOfSelectedUserIDs.isEmpty) {
      listOfSelectedUserIDs.add(user.id);
    }

    emit(
      state.copyWith(listOfSelectedUserIDs: listOfSelectedUserIDs, userIndex: userIndex),
    );
  }

The differences between selectUserToSendCapturedPhoto and selectUserWhenCreatingAGroup are we use the index in the selectUserToSendCapturedPhoto function. So that we can play with the UI when we select the user to send a photo. When we remove the user, then we set the index value as the initial value.

  bool searchInsideExistingChannels({
    required List<Channel> listOfChannels,
    required String searchedText,
    required int index,
    required int lengthOfTheChannelMembers,
    required User oneToOneChatMember,
  }) {
    int result;
    final editedSearchedText = searchedText.toLowerCase().trim();

    if (lengthOfTheChannelMembers == 2) {
      final filteredChannels = listOfChannels
          .where(
            (channel) => oneToOneChatMember.name.toLowerCase().trim().contains(editedSearchedText),
          )

Lastly, this function is for the searching channels (chats). So, we can filter the channel by name, and after that, we can show it in the UI. We use the indexOf function to take advantage of its value. If the result field is equal to -1, then we need to understand that there is no searched value, and we return false. If it returns other than -1, so that actually else, we return true. With this, we can figure out there is a user who contains the searched text. Thus, we can show the user with a Card widget or directly show an empty Container widget.

Presentation (UI)

The only thing in the presentation folder we need to do is set the StreamChat widget. As you can see, the StreamChat widget says, “Widget used to provide information about the chat to the widget tree.” Therefore, we need to set it at the root of the tree.
In the MaterialApp widget, there is a parameter that name is a builder.

 MaterialApp.router(
            debugShowCheckedModeBanner: false,
            routerConfig: appRouter.router,
            localizationsDelegates: const [...],
            supportedLocales: const [...],
            builder: (context, child) {
              final client = getIt<StreamChatClient>();

              child = StreamChat(
                client: client,
                child: child,
              );
              child = botToastBuilder(context, child);

              return child;
            },
          ),

Since we use the botToast package, we need to set both the StreamChat widget and botToastBuilder function. Then, return the child (If you have only one child, then you do not have to set the field to the child). The client is coming from the get_it service via the dependency injection that we created earlier.

Conclusion

Today, we covered how to use high-quality packages with high-quality services in Flutter.

You can visit my GitHub account to reach the full application: https://github.com/alperefesahin/flutter_social_chat

decorative lines
Integrating Video With Your App?
We've built an audio and video solution just for you. Launch in days with our new APIs & SDKs!
Check out the BETA!