Learn how to quickly integrate rich Generative AI experiences directly into Stream Chat. Learn More

Build a Flutter Chat App With The Stream SDK

Build a mobile chat application similar to Facebook Messenger or WhatsApp using Stream's Flutter Chat SDK library. By the end of this tutorial, you will have a fully functioning mobile app with support for rich messages, reactions, threads, image uploads and videos.

We are also going to show how easy it is to make customizations to the built-in widgets that ship with this library and their styling.

In this tutorial, we will use the Stream SDK for Flutter to add chat capabilities to our Flutter apps. The SDK for Flutter is available along with the docs.

This tutorial has been checked to work on:

  • Flutter 3.32 (or latest)
  • Stream Chat SDK for Flutter 10.0.0 (or latest)

By the end of this tutorial, we will have an application that has:

  • Stream Chat Client integrated and set up.
  • Set up multiple conversations in the app.
  • Customize the preview for channels.
  • Provide custom UI for the messages.

For inspiration, this repository contains sample Flutter projects to demonstrate the use of Stream Chat SDK for Flutter.

Flutter Chat SDK Setup

First of all we need to setup our new project and have a working development environment for Flutter. If you do not have Flutter or an IDE configured to work with it, we highly recommend following the Install and Set up an editor steps from the official documentation.

Please make sure you are using the latest version of Flutter from the stable channel:

bash
1
2
flutter channel stable flutter upgrade

Now open your IDE and start a new Flutter application called awesome_flutter_chat. If you are using Android Studio (recommended) make sure to create the project as a Flutter application and keep all default settings.

Set up demo messaging app in Android Studio

Next step is to add stream_chat_flutter to your dependencies, to do that just open pubspec.yaml and add it inside the dependencies section.

yaml
1
2
3
4
5
dependencies: flutter: sdk: flutter stream_chat_flutter: ^10.0.0

Stream has several packages that you can use to integrate chat into your application.

In this tutorial, we will be using the stream_chat_flutter package which contains pre-built UI elements for you to use.

If you need more control over the UI, stream_chat_flutter_core provides bare-bones implementation with logic and controllers that you can use to build your own UI.

For the most possible control, the stream_chat package allows access to the low-level client.

Add Stream Chat to your Flutter application

Let's start by adding the top-level Chat widget and initialize your application. There are three important things to notice that are common to all Flutter application using StreamChat:

  1. The Dart API client is initialized with your API Key
  2. The current user is set by calling connectUser on the client
  3. The client is then passed to the top-level StreamChat widget

StreamChat is an inherited widget and must be the parent of all Chat related widgets, we will cover later how you can leverage the inherited widget APIs to build more advanced customizations to your application.

Let's open the main.dart file and replace its content with this:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() async { /// Create a new instance of [StreamChatClient] passing the apikey obtained from your /// project dashboard. final client = StreamChatClient( 'b67pax5b2wdq', logLevel: Level.INFO, ); /// Set the current user. In a production scenario, this should be done using /// a backend to generate a user token using our server SDK. /// Please see the following for more information: /// https://getstream.io/chat/docs/flutter-dart/tokens_and_authentication/ await client.connectUser( User(id: 'tutorial-flutter'), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZmx1dHRlciJ9.S-MJpoSwDiqyXpUURgO5wVqJ4vKlIVFLSEyrFYCOE1c', ); /// Creates a channel using the type `messaging` and `flutterdevs`. /// Channels are containers for holding messages between different members. To /// learn more about channels and some of our predefined types, checkout our /// our channel docs: https://getstream.io/chat/docs/flutter-dart/creating_channels/ /// /// We pass the current user inside `extraData.members` so the channel is /// created with them already added — this is what makes the channel appear /// in the member-filtered query we use in the next step. final channel = client.channel( 'messaging', id: 'flutterdevs', extraData: const { 'members': ['tutorial-flutter'], }, ); /// `.watch()` is used to create and listen to the channel for updates. If the /// channel already exists, it will simply listen for new events. await channel.watch(); runApp( MyApp( client: client, channel: channel, ), ); } class MyApp extends StatelessWidget { const MyApp({ super.key, required this.client, required this.channel, }); /// Instance of [StreamChatClient] we created earlier. This contains /// information about our application and connection state. final StreamChatClient client; /// The channel we'd like to observe and participate in. final Channel channel; Widget build(BuildContext context) { return MaterialApp( builder: (context, widget) { return StreamChat( client: client, child: widget, ); }, home: StreamChannel( channel: channel, child: const ChannelPage(), ), ); } } /// Displays the list of messages inside the channel. class ChannelPage extends StatelessWidget { const ChannelPage({super.key}); Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; return Scaffold( backgroundColor: colorScheme.backgroundApp, appBar: const StreamChannelHeader(), body: Column( children: <Widget>[ const Expanded( child: StreamMessageListView(), ), StreamMessageComposer(), ], ), ); } }

Tutorial Screen

Please note that while Flutter can be used to build both mobile and web applications; in this tutorial we are going to focus on mobile, make sure when running the app you use a mobile device.

Launch the app in Run mode. The dependencies emit a lot of signals and debugger captures these events and pauses the execution of the code.

Let's have a look at what we've built so far:

  • We set up the Chat Client with the API key
  • We set the the current user for Chat with StreamChatClient.connectUser and a pre-generated user token
  • We create a messaging channel with id flutterdevs, passing the current user inside extraData.members so they're a member from the moment the channel is created - this is what makes the channel appear in the member-filtered query we use in the next step
  • We make StreamChat the root Widget of our application
  • We create a single ChannelPage widget under StreamChat with three widgets: StreamChannelHeader, StreamMessageListView and StreamMessageComposer
  • We paint the Scaffold background with context.streamColorScheme.backgroundApp so the screen blends with the SDK's theme tokens

If you now run the simulator you will see a single channel UI.

Rich Messaging

The widgets we have dropped on our code earlier provide several rich interactions out of the box.

  1. URL previews
  2. User mentions
  3. Chat commands
  4. Image uploads

Multiple Conversations

Most chat applications handle more than just one single conversation. Apps like Facebook Messenger, Whatsapp and Telegram allows you to have multiple one to one and group conversations.

Let's find out how we can change our application chat screen to display the list of conversations and navigate between them.

Note: the SDK uses Flutter's Navigator to move from one route to another, this allows us to avoid any boiler-plate code. Of course you can take total control of how navigation works by customizing widgets like StreamChannel and StreamChannelListView.

Let's open again main.dart and make some small changes:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; void main() async { final client = StreamChatClient( 'b67pax5b2wdq', logLevel: Level.INFO, ); await client.connectUser( User(id: 'tutorial-flutter'), 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZmx1dHRlciJ9.S-MJpoSwDiqyXpUURgO5wVqJ4vKlIVFLSEyrFYCOE1c', ); runApp( MyApp( client: client, ), ); } class MyApp extends StatelessWidget { const MyApp({ super.key, required this.client, }); /// Instance of [StreamChatClient] we created earlier. This contains /// information about our application and connection state. final StreamChatClient client; Widget build(BuildContext context) { return MaterialApp( builder: (context, child) => StreamChat( client: client, child: child, ), home: const ChannelListPage(), ); } } /// Displays the list of channels for the current user. class ChannelListPage extends StatefulWidget { const ChannelListPage({super.key}); State<ChannelListPage> createState() => _ChannelListPageState(); } class _ChannelListPageState extends State<ChannelListPage> { late final _listController = StreamChannelListController( client: StreamChat.of(context).client, filter: Filter.in_( 'members', [StreamChat.of(context).currentUser!.id], ), channelStateSort: const [SortOption.desc('last_message_at')], limit: 20, ); void dispose() { _listController.dispose(); super.dispose(); } Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; return Scaffold( backgroundColor: colorScheme.backgroundApp, appBar: const StreamChannelListHeader(), body: StreamChannelListView( controller: _listController, onChannelTap: (channel) { Navigator.of(context).push( MaterialPageRoute( builder: (context) { return StreamChannel( channel: channel, child: const ChannelPage(), ); }, ), ); }, ), ); } } /// Displays the list of messages inside the channel. class ChannelPage extends StatelessWidget { const ChannelPage({super.key}); Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; return Scaffold( backgroundColor: colorScheme.backgroundApp, appBar: const StreamChannelHeader(), body: Column( children: <Widget>[ const Expanded( child: StreamMessageListView(), ), StreamMessageComposer(), ], ), ); } }

Chat List Screen

If you run your application now, you will see that the first screen shows a list of conversations, you can open each by tapping and go back to the list.

Every single widget involved in this UI can be customized or swapped with your own.

The ChannelListPage widget retrieves the list of channels based on a custom query and ordering as defined in the instance of the StreamChannelListController. In this case we are showing the list of channels the current user is a member and we order them based on the time they had a new message. StreamChannelListView handles pagination and updates automatically out of the box when new channels are created or when a new message is added to a channel.

We use StreamChannelListHeader as the appBar to render a title, the current user's avatar, and the connection state - matching the look of the StreamChannelHeader we used on the channel page.

Customize Channel Preview

So far you've learned how to use the default widgets. The library has been designed with composition in mind and to allow all common customizations to be very easy. This means that you can change any component in your application by swapping the default widgets with the ones you build yourself.

Let's see how we can make some changes to the SDK's UI components. We will start by changing how channel tiles are shown in the channel list and include the number of unread messages for each.

We're passing a custom widget to itemBuilder in the StreamChannelListView, this will override the default StreamChannelListItem and allows you to create one yourself.

Add the collections dependency to your project.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
/// Displays the list of channels for the current user. class ChannelListPage extends StatefulWidget { const ChannelListPage({super.key}); State<ChannelListPage> createState() => _ChannelListPageState(); } class _ChannelListPageState extends State<ChannelListPage> { late final _listController = StreamChannelListController( client: StreamChat.of(context).client, filter: Filter.in_( 'members', [StreamChat.of(context).currentUser!.id], ), channelStateSort: const [SortOption.desc('last_message_at')], limit: 20, ); void dispose() { _listController.dispose(); super.dispose(); } Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; return Scaffold( backgroundColor: colorScheme.backgroundApp, appBar: const StreamChannelListHeader(), body: StreamChannelListView( controller: _listController, itemBuilder: _channelTileBuilder, onChannelTap: (channel) { Navigator.of(context).push( MaterialPageRoute( builder: (context) { return StreamChannel( channel: channel, child: const ChannelPage(), ); }, ), ); }, ), ); } Widget _channelTileBuilder(BuildContext context, List<Channel> channels, int index, StreamChannelListItem defaultChannelListItem) { final channel = channels[index]; final lastMessage = channel.state?.messages.reversed.firstWhereOrNull( (message) => !message.isDeleted, ); final subtitle = lastMessage == null ? 'nothing yet' : lastMessage.text!; final unreadCount = channel.state?.unreadCount ?? 0; final opacity = unreadCount > 0 ? 1.0 : 0.5; return ListTile( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (_) => StreamChannel( channel: channel, child: const ChannelPage(), ), ), ); }, leading: StreamChannelAvatar( channel: channel, ), title: StreamChannelName( channel: channel, textStyle: StreamChannelListItemTheme.of(context).titleStyle?.copyWith( color: context.streamColorScheme.textPrimary.withValues(alpha: opacity), ), ), subtitle: Text(subtitle), trailing: unreadCount > 0 ? CircleAvatar( radius: 10, child: Text(unreadCount.toString()), ) : const SizedBox(), ); } }

You may need to import the collections package for firstWhereOrNull to work. If you see an error, import:

dart
1
import 'package:collection/collection.dart';

Channel Preview Screen

There are a couple interesting things we do in this widget:

  • Instead of creating a whole new style for the channel name, we inherit the text style from the channel-list item theme (StreamChannelListItemTheme.of(context).titleStyle) and only change the color attribute, dimming it for read channels via context.streamColorScheme.textPrimary.withValues(alpha: opacity)
  • We loop over the list of channel messages to search for the first not deleted message (channel.state?.messages)
  • We retrieve the count of unread messages from channel.state?.unreadCount

Message Threads

Stream Chat supports message threads out of the box. Threads allows users to create sub-conversations inside the same channel.

Using threaded conversations is very simple and mostly a matter of plugging the StreamMessageListView to another widget that renders the widget. To make this simple, such a widget only needs to build StreamMessageListView with the parent attribute set to the thread's root message.

Here's how the builder and the change to the channel page looks like:

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
/// Displays the list of messages inside the channel. class ChannelPage extends StatelessWidget { const ChannelPage({super.key}); Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; return Scaffold( backgroundColor: colorScheme.backgroundApp, appBar: const StreamChannelHeader(), body: Column( children: <Widget>[ Expanded( child: StreamMessageListView( threadBuilder: (_, parentMessage) => ThreadPage( parent: parentMessage!, ), ), ), StreamMessageComposer(), ], ), ); } } /// Displays the thread replies for a parent message. class ThreadPage extends StatefulWidget { const ThreadPage({ super.key, required this.parent, }); /// The root message this thread is replying to. final Message parent; State<ThreadPage> createState() => _ThreadPageState(); } class _ThreadPageState extends State<ThreadPage> { late final _controller = StreamMessageComposerController( message: Message(parentId: widget.parent.id), ); void dispose() { _controller.dispose(); super.dispose(); } Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; return Scaffold( backgroundColor: colorScheme.backgroundApp, appBar: StreamThreadHeader( parent: widget.parent, ), body: Column( children: <Widget>[ Expanded( child: StreamMessageListView( parentMessage: widget.parent, ), ), StreamMessageComposer( messageComposerController: _controller, ), ], ), ); } }

Message Threads Screen

Now we can open threads and create new ones as well, if you long press a message you can tap on Reply and it will open the same ThreadPage.

Custom Message

Customizing how messages are rendered is another very common use-case that the SDK supports easily.

Replacing the built-in message component with your own is done by passing a builder function as the messageBuilder parameter of the StreamMessageListView widget.

The builder receives the usual BuildContext, the Message being rendered, and a StreamMessageItemProps object that already has every list-level callback wired in. You can use the props as-is to build the default message item, or call .copyWith(...) to tweak individual fields before rendering.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/// Displays the list of messages inside the channel with a custom message widget. class ChannelPage extends StatelessWidget { const ChannelPage({super.key}); Widget build(BuildContext context) { final colorScheme = context.streamColorScheme; return Scaffold( backgroundColor: colorScheme.backgroundApp, appBar: const StreamChannelHeader(), body: Column( children: <Widget>[ Expanded( child: StreamMessageListView( messageBuilder: _messageItemBuilder, ), ), StreamMessageComposer(), ], ), ); } Widget _messageItemBuilder( BuildContext context, Message message, StreamMessageItemProps defaultProps, ) { final isCurrentUser = StreamChat.of(context).currentUser!.id == message.user!.id; final textAlign = isCurrentUser ? TextAlign.right : TextAlign.left; final color = isCurrentUser ? Colors.blueGrey : Colors.blue; return Padding( padding: const EdgeInsets.all(5), child: DecoratedBox( decoration: BoxDecoration( border: Border.all( color: color, ), borderRadius: const BorderRadius.all( Radius.circular(5), ), ), child: ListTile( title: Text( message.text!, textAlign: textAlign, ), subtitle: Text( message.user!.name, textAlign: textAlign, ), ), ), ); } }

Custom Message Screen

If you look at the code you can see that we use StreamChat.of to retrieve the current user so that we can style own messages in a different way.

Since custom widgets and builders are always children of StreamChat or part of a channel, you can use StreamChat.of, StreamChannel.of and StreamChatTheme.of to use the API client directly or to retrieve outer scope needed such as messages from channel.state.

The third parameter of the messageBuilder function - StreamMessageItemProps - describes everything the SDK would have used to render the default message item. If you want to keep most of the default behaviour and only tweak a few things, call defaultProps.copyWith(...) and pass the result to DefaultStreamMessageItem instead of returning a fully custom widget.

Custom Styles

The Flutter SDK ships fully designed widgets that you can theme to match your app. Theming works in two layers:

  • Design tokens - a StreamTheme registered as a ThemeData extension. Set a brand color here and Stream derives the rest of its semantic palette from the swatch automatically.
  • Per-widget overrides - a StreamChatThemeData passed to StreamChat.themeData. Tweak individual components without touching the rest of the theme.

We'll start with the brand color, then layer a granular override on top.

Register a StreamTheme extension on MaterialApp.theme (and a matching one on darkTheme) with a custom green brand swatch. Message bubbles, sending indicators, unread badges, the composer cursor, the audio recorder, and other accents all pick up the new tone in one go - we'll see the combined result alongside the per-widget override below.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class MyApp extends StatelessWidget { const MyApp({ super.key, required this.client, }); /// Instance of [StreamChatClient] we created earlier. This contains /// information about our application and connection state. final StreamChatClient client; Widget build(BuildContext context) { final greenBrand = StreamColorSwatch.fromColor(Colors.green); final greenBrandDark = StreamColorSwatch.fromColor(Colors.green, brightness: Brightness.dark); return MaterialApp( theme: ThemeData( brightness: Brightness.light, extensions: [ StreamTheme( brightness: Brightness.light, colorScheme: StreamColorScheme.light(brand: greenBrand), ), ], ), darkTheme: ThemeData( brightness: Brightness.dark, extensions: [ StreamTheme( brightness: Brightness.dark, colorScheme: StreamColorScheme.dark(brand: greenBrandDark), ), ], ), builder: (context, child) => StreamChat( client: client, child: child, ), home: const ChannelListPage(), ); } }

Now let's layer a per-widget override on top. StreamChatThemeData exposes a theme object for every Stream component; we'll tweak channelListItemTheme to give every channel a light green tint and bolder titles, reusing greenBrand.shade100 - the same swatch shade Stream uses to paint outgoing message bubbles - so the channel list and the message list share the same green tone.

Build the customTheme inside build() and pass it to StreamChat via the themeData argument. The same pattern works for any other Stream component: provide the per-component *ThemeData you want to override and Stream merges it on top of the defaults.

dart
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
class MyApp extends StatelessWidget { const MyApp({ super.key, required this.client, }); /// Instance of [StreamChatClient] we created earlier. This contains /// information about our application and connection state. final StreamChatClient client; Widget build(BuildContext context) { final greenBrand = StreamColorSwatch.fromColor(Colors.green); final greenBrandDark = StreamColorSwatch.fromColor(Colors.green, brightness: Brightness.dark); final customTheme = StreamChatThemeData( channelListItemTheme: StreamChannelListItemThemeData( titleStyle: const TextStyle(fontWeight: FontWeight.bold), backgroundColor: WidgetStateProperty.all(greenBrand.shade100), ), ); return MaterialApp( theme: ThemeData( brightness: Brightness.light, extensions: [ StreamTheme( brightness: Brightness.light, colorScheme: StreamColorScheme.light(brand: greenBrand), ), ], ), darkTheme: ThemeData( brightness: Brightness.dark, extensions: [ StreamTheme( brightness: Brightness.dark, colorScheme: StreamColorScheme.dark(brand: greenBrandDark), ), ], ), builder: (context, child) => StreamChat( client: client, themeData: customTheme, child: child, ), home: const ChannelListPage(), ); } }

Custom Styles Channel List

Custom Styles Channel Page

Conclusion

In this tutorial, we built a Flutter application that exposes chat capabilities and allows you to connect to the Stream SDK, list the channels, view the channels, and send messages to the channel.

Now that you've completed the tutorial on Stream Chat, you can use our chat SDK to build your apps. If you have a use-case that doesn't quite seem to work, or simply have questions, please don't hesitate to reach out to our team.

Final Thoughts

In this chat app tutorial we built a fully functioning Flutter messaging app with our Flutter SDK component library. We also showed how easy it is to customize the behavior and the style of the Flutter chat app components with minimal code changes.

Both the chat SDK for Flutter and the API have plenty more features available to support more advanced use-cases such as push notifications, content moderation, rich messages and more. Please check out our React tutorial too. If you want some inspiration for your app, download our free chat interface UI kit.

Give us feedback!

Did you find this tutorial helpful in getting you up and running with your project? Either good or bad, we're looking for your honest feedback so we can improve.

Start coding for free

No credit card required.
If you're interested in a custom plan or have any questions, please contact us.