Building a Messaging App with Flutter

Idorenyin O.
Idorenyin O.
Published April 26, 2020 Updated July 15, 2020

Flutter is the latest cross-platform UI toolkit (provided by Google) for building Android, iOS, and even desktop apps that is gaining popularity amongst developers. Stream Chat, on the other hand, is an enterprise-grade chat solution that offers extensive APIs and SDKs to power chat on multiple platforms. When they come together, magic happens... 🔮

In this tutorial, you will learn how to build a one-on-one chat application on Flutter using Stream's JavaScript (Node.js) API and Flutter SDK.

If you'd simply like to see the code for the final project, check out the GitHub repo.

Prerequisites

To proceed with this tutorial, you'll need the following:

With all these in check, feel free to proceed!

Creating a Stream App

The first thing we will do is create a new app in your Stream Dashboard.

If you’re already on the Stream home page, the Dashboard button is at the top-right corner:

Screenshot of the Stream Chat Homepage

If you've just created a new account, you might notice that an app was already created for you; you can use this one or create another. To create an app from your dashboard, use the Create App button:

Screenshot of the Stream Dashboard

Once you click the button, you will see a dialog to get the details of your app. You can populate it with the following information:

Screenshot of the Create New App Modal

Note that if you're creating your app for production, rather than just as a demo for this tutorial, you should select the "Production" option.

After creating your app, you will see it listed on your dashboard:

Screenshot of Stream Dashboard with Newly-Created App

Copy out the KEY and SECRET values, as you will need them in subsequent sections of this tutorial.

Setting Up the Backend

Next up, we will build the backend app using Node.js. Go ahead and create a directory named nodejs-backend. If you’re using a Unix machine, you can do so by running this command:

sh
1
$ mkdir nodejs-backend

Installing the Backend Packages

cd into the nodejs-backend directory we just created and run this command:

sh
1
$ npm init -y

This command will generate a package.json file in the nodejs-backend directory. The package.json file is the heart of any Node.js project; this file will contain important data about the project.

Next, we can install all of the packages we will make use of. While still, in the nodejs-backend directory, run this command:

sh
1
$ npm install stream-chat express body-parser

With this command, we are installing three packages:

  • stream-chat: This is Stream’s JavaScript SDK. We will use it to perform some server-side operations, like generating the JSON Web Token (JWT) for the app.
  • express: Express.js is a Node.js framework that makes it easier to build Node.js apps.
  • body-parser: This is a middleware used to extract the body of an incoming request.

The Heart and Soul of the Backend

Next, create a new index.js file in the nodejs-backend directory. Inside the index.js file, add this snippet:

const express = require('express');
const StreamChat = require('stream-chat').StreamChat;
const bodyParser = require('body-parser');
const app = express();

const client = new StreamChat('your_API_KEY', 'your_API_SECRET');

app.use(bodyParser.json());
app.post('/token', (req, res, next) => {
    const userToken = client.createToken(req.body.userId);
    res.json({success: 200, token: userToken});
});

app.listen(4000, _ => console.log('App listening on port 4000!'));

Make sure to replace "your_API_KEY" and "your_API_SECRET" (in the code above) with the credentials from your Stream app dashboard.

From the snippet above, the server has one endpoint (/token), which takes the userId and generates a token for the user (userToken).

Ideally, for a production app, you will ensure that whoever is providing the userId is properly authenticated against your database.

Starting Up the Backend

After that, run the server using this command:

sh
1
$ node index.js

This will serve the app on port 4000. Next, we will use Ngrok to provide a secure endpoint for the local server:

sh
1
$ ./ngrok http 4000

After running that, you should see something like this:

ngrok

Now, copy out the secure URL (https://0a83ad73.ngrok.io, in my case) for use later in our app development.

Building the Flutter App

Now we’re ready to build the Flutter app! In this tutorial, the screenshots will be that of Android Studio. However, you can also use VS Code to build your app.

Start by creating a new Flutter application by selecting the Flutter Application option and clicking Next:

Screenshot of Creating a New Flutter Application in Android Studio

After that, we'll need to fill in the application details:

Screenshot of New Flutter Application Configuration Window

Here, you'll need to enter the Project name, Project location, and Description. You'll also have to reference the Flutter SDK path; if you properly installed the Flutter SDK, the system should automatically detect that for you.

Finally, for the project creation wizard, you need to enter a Package name and choose to enable the Platform channel languages:

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!
Screenshot of New Flutter Application Package Name Window

Then, just click Finish, and Android Studio will bootstrap the project for you!

The Code

Now that the project is ready, the next step is to add packages that we will use in the app.

Open the pubspec.yaml file and add the following packages in the dependencies section:

yaml
1
2
stream_chat_flutter: ^0.1.2 http: ^0.12.0+4

Your pubspec.yaml file should now look like this:

name: flutterchatapp
description: A Flutter chat application built with Stream.
version: 1.0.0+1

environment:
  sdk: ">=2.1.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  stream_chat_flutter: ^0.1.2
  http: ^0.12.0+4
  cupertino_icons: ^0.1.2

dev_dependencies:
  flutter_test:
    sdk: flutter

flutter:
  uses-material-design: true

Here, we added two packages:

  • stream_chat_flutter: This package is Stream’s Flutter package, which will give us access to chat capabilities.
  • http: We need this package so we can make API calls.

After adding the packages, click the Packages get action in the upper right corner of the window:

Screenshot of "Packages get" Location in Android Studio

This will download the packages we just added!

Building the Login, Users List, and Chat Screen

Our app will have three screens: the Login screen, the Users List screen, and the Chat screen.

The Login Screen

Let's start with the Login screen. Create a new file called login_page.dart and add the following code:

import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutterchatapp/users_list_page.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

import 'package:http/http.dart' as http;

class LoginPage extends StatefulWidget {
  @override
  _LoginPageState createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  var _userIDController = TextEditingController();
  final GlobalKey<ScaffoldState> _scaffoldKey = new GlobalKey<ScaffoldState>();
  final _client = Client('STREAM_API_KEY', logLevel: Level.INFO);

  @override
  void dispose() {
    _userIDController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      key: _scaffoldKey,
      body: SafeArea(
        child: Container(
          margin: EdgeInsets.only(left: 20, right: 20, top: 50),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.start,
            crossAxisAlignment: CrossAxisAlignment.start,
            children: <Widget>[
              Text('Login',
                  style: TextStyle(fontWeight: FontWeight.w500, fontSize: 20)),
              Container(
                  margin: EdgeInsets.only(top: 30),
                  child: Text('Enter your unique user id',
                      style: TextStyle(
                          fontSize: 14, fontWeight: FontWeight.w400))),
              Container(
                  margin: EdgeInsets.only(top: 5),
                  child: TextFormField(
                      controller: _userIDController,
                      decoration: InputDecoration(
                          enabledBorder: OutlineInputBorder(
                              borderSide: BorderSide(
                                  color: Color(0xFFCBD2D9), width: 1)),
                          focusedBorder: OutlineInputBorder(
                              borderSide: BorderSide(
                                  color: Color(0xFFCBD2D9),
                                  width: 1,
                                  style: BorderStyle.solid))))),
              Padding(
                  padding: const EdgeInsets.only(top: 10.0),
                  child: RaisedButton(
                      onPressed: () {
                        _loginUser();
                      },
                      child: Text('Continue')))
            ],
          ),
        ),
      ),
    );
  }

  _loginUser() async {
    final userID = _userIDController.text.trim();

    if (userID.isEmpty) {
      SnackBar snackBar = SnackBar(content: Text('User id is empty'));
      _scaffoldKey.currentState.showSnackBar(snackBar);
      return;
    }

    var url = "YOUR_NGROK_URL/token";
    Map<String, String> headers = new Map();
    headers['Content-Type'] = 'application/json';
    var body = json.encode({
      "userId": userID,
    });

    var tokenResponse = await http.post(url, body: body, headers: headers);

    var userToken = jsonDecode(tokenResponse.body)['token'];

    await _client.setUser(User(id: userID), userToken).then((response) {
      Navigator.push(
        context,
        MaterialPageRoute(
            builder: (context) => StreamChat(
                  client: _client,
                  child: UsersListPage(),
                )),
      );
    }).catchError((error) {
      print(error);
      SnackBar snackBar = SnackBar(content: Text('Could not login user'));
      _scaffoldKey.currentState.showSnackBar(snackBar);
    });
  }
}

We start by importing the files we will access throughout the course of building this screen. After that, we create a stateful widget called "LoginPage"; as the name "stateful widget" implies, this widget will have a state called "_LoginPageState".

We then go on to create the _LoginPageState class; in this class, we start by declaring the variables we'll use throughout the class:

  • userIDController: the controller of the TextFormField
  • _scaffoldKey: will serve as the key for the Scaffold widget
  • _client: object that is an instance of the Client class provided by the Stream Flutter SDK.

Be sure to replace STREAM_API_KEY with the key from your Stream app and YOUR_NGROK_BASE_URL with the secure Ngrok URL, which you got earlier on.

In this class, we also implement the layout of the Login screen, via the build() method. The layout contains a TextFormField to accept the user’s id and a button to trigger the log in action.

Here is what we've just built:

Completed Login Screen

Apart from the build() method, the _LoginPageState class has two other methods:

  • dispose(): In this method, we dispose of the _userIDController object when it is no longer in use. This is a good practice to keep the memory allocation of your app in check.
  • _loginUser(): In this method, we first validate the user id to ensure the user did not submit empty text. In the case that no text was entered, the method stops and displays an error message to the user. If the user has typed something, we make an API call to the /token endpoint to get a token for the user.

After we get the token, we call the setUser method of the Client class. This method notifies the Stream SDK of the current user in the session. Once everything is successful, the user will be directed to the UsersListPage. This page does not exist yet; let's create it!

The Users List Screen

The next thing we will do is build the Users List page. Create a new file called users_list_page.dart and add the following code:

import 'package:flutter/material.dart';
import 'package:flutterchatapp/channel_page.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

class UsersListPage extends StatefulWidget {
  @override
  _UsersListPageState createState() => _UsersListPageState();
}

class _UsersListPageState extends State<UsersListPage> {
  List<User> _usersList = [];
  bool _loadingData = true;

  @override
  void initState() {
    super.initState();
    _fetchUsers();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: SafeArea(
          child: _loadingData
              ? Container(child: Center(child: CircularProgressIndicator()))
              : _usersList.length == 0
                  ? Container(
                      child: Center(child: Text('Could not fetch users')))
                  : ListView.separated(
                      padding: EdgeInsets.all(16.0),
                      separatorBuilder: (context, index) {
                        return Divider();
                      },
                      itemBuilder: (BuildContext context, int index) {
                        return ListTile(
                          title: Text(_usersList[index].name),
                          onTap: () {
                            _navigateToChannel(index);
                          },
                        );
                      },
                      itemCount: _usersList.length)),
    );
  }

  _fetchUsers() async {
    setState(() {
      _loadingData = true;
    });

    StreamChat.of(context)
        .client
        .queryUsers({}, [SortOption('last_message_at')], null).then((value) {
      setState(() {
        if (value.users.length > 0) {
          _usersList = value.users.where((element) {
            return element.id != StreamChat.of(context).user.id;
          }).toList();
        }
        _loadingData = false;
      });
    }).catchError((error) {
      setState(() {
        _loadingData = false;
      });
      print(error);
      // Could not fetch users
    });
  }

  void _navigateToChannel(int index) async {
    var client = StreamChat.of(context).client;
    var currentUser = StreamChat.of(context).user;

    Channel channel;

    await client
        .channel("messaging", extraData: {
          "members": [currentUser.id, _usersList[index].id]
        })
        .create()
        .then((response) {
          channel = Channel.fromState(client, response);
          channel.watch();
        })
        .catchError((error) {
          print(error);
        });

    if (channel != null) {
      Navigator.push(
        context,
        MaterialPageRoute(
          builder: (context) {
            return StreamChannel(
              child: ChannelPage(),
              channel: channel,
            );
          },
        ),
      );
    } else {
      // Could not find a channel;
    }
  }
}

Similar to the login screen, we declare the imports and then create a stateful widget called UsersListPage. After that, we create the _UsersListPageState. In the _UsersListPageState, we declare two variables:

  • _usersList: to keep data of users the logged-in user can chat with.
  • _loadingData: to know when the app is fetching any sort of data.

As usual, we've built the layout in the build() method. The layout will have three different displays depending on the outcome of fetching users: if data is still fetching, there will be a loading indicator displayed on the screen; if the fetch is complete and the usersList is still empty, it will show an error message; and if the _usersList has data, it shows a list view of the users.

We reference the _fetchUsers() method in the initState() of the class. This is so that the method is called as soon as the user opens this page.

In the _fetchUsers() method, we first set the value of _loadingData to true, using the setState function. Using the setState() function signifies that the state of the page has changed, and so the build() method is rendered again to reflect the new changes.

After that, we get the client object (passed down from the parent widget) and query for users. Once this is successful, we filter the users to omit the logged-in user, and then we assign the result to the _usersList object. We also set _loadingData to false.

When the app displays a list of users, there is a click listener added to take action based on which user is selected. The _navigateToChannel() method is then called when a list item is selected.

In the method, we are creating a channel between the logged-in user and the user that was selected on the list. Note that if this channel has been created before (the logged-in user has previously chatted with the selected user), the Stream API will ensure that only one channel for the members we specified exists. If everything is successful, the app will open the ChannelPage next.

The Chat Screen

To finish setting up the app screens, create another file called channel_page.dart and add the following code:

import 'package:flutter/material.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';

class ChannelPage extends StatelessWidget {
  const ChannelPage({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: ChannelHeader(),
      body: Column(
        children: <Widget>[
          Expanded(
            child: MessageListView(),
          ),
          MessageInput(),
        ],
      ),
    );
  }
}

In this page, we are making use of Stream’s built-in widgets. They include:

  • ChannelHeader: This widget is an AppBar widget that will display the channel name and display the profile picture of the logged-in user.
  • MessageListView: This widget will display the messages in real time.
  • MessageInput: This widget will enable the user to send messages (Not only text messages, but photos, videos, etc).

This is one of the advantages of using Stream. They provide inbuilt widgets you can easily make use of to help you build chats in record time!

To complete the Chat screen setup, replace the main.dart file with the following:

import 'package:flutter/material.dart';
import 'package:flutterchatapp/login_page.dart';

void main() => runApp(MyApp());

class MyApp extends StatelessWidget {
  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: LoginPage(),
    );
  }
}

Here, we’re making the LoginPage the first screen to be displayed.

At this point, the app is good to go!

Wrapping Up

In this tutorial, you have learned how to build a one on one chat using Flutter and the most powerful chat API out there - Stream. There is still so much that can be done with Stream’s API! Feel free to explore the official docs.

Thanks for reading, and Happy Coding!

Integrating Video With Your App?
We've built a Video and Audio solution just for you. Check out our APIs and SDKs.
Learn more ->