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

Building A Flutter Chat App With Stream

Ready to get started? In this tutorial we cover everything you need to know in order to build a Flutter chat app with the Stream Chat SDK.

At the end of the tutorial, you will have created a fully functional messaging application that you can easily extend until it meets your particular use case.

Prefer to skip the setup? Add the Stream skill and let your AI agent build your Flutter chat app.

This tutorial teaches you how to build a fully featured Flutter messaging app with the Stream Chat SDK - channel list, message composer, reactions, threads, typing indicators, and offline support. You can use it as the foundation to build any type of in-app chat or messaging.

On the right is a preview of the finished app. A complete sample app is also available in our repo.

This page works for both humans and AI coding agents: every step is a file operation or shell command, every step has a verification checkpoint, and the few steps that need a human are explicitly marked.

Your app runs on Stream's edge network for optimal performance, across multiple pricing tiers including a free maker plan.

Choose your path

This tutorial has two paths. In Path A you let your AI agent build the app for you. In Path B you build it yourself, step by step. Both paths end with a working chat app - Path A's is shaped by what you ask for, Path B's by the steps below - so pick one instead of working through both.

Install the Stream CLI once, then add the skills. This gives Claude Code, Cursor, or Codex the Flutter integration patterns and current SDK APIs, so it builds against real docs instead of stale training data.

bash
1
2
3
4
5
6
7
8
9
# Install the getstream CLI curl -fsSL https://getstream.io/cli.sh | bash # Install the skills (router, docs, and builder) for your agent. Pick the # target: --universal for Cursor, Codex, and other AGENTS-ecosystem tools, # or --claude for Claude Code. The Flutter pack installs on demand the first # time it's needed, or add it explicitly: getstream skills --universal getstream skills stream-flutter --universal

Then ask your agent:

Prompt (markdown)
1
2
3
4
/stream-flutter Build a Flutter chat app with a channel list, a channel screen and a thread screen. Provision credentials with the CLI: create or select my org and app, then mint a token. If you can't mint one, fall back to the demo credentials in https://getstream.io/chat/sdk/flutter/tutorial.md

Only the first line matters - /stream-flutter Build a Flutter chat app is enough to get going. The rest just steers the result: ask for a different screen split, your own theme, custom message bubbles, or a search screen before the channel list. Describe the UI you want rather than the UI the tutorial builds.

The agent pauses and hands back to you twice, both during setup: browser sign-in during CLI setup, and picking which org and app to use during getstream init. After that it scaffolds and builds the app itself - though depending on what your agent is allowed to run, it may ask you to run flutter create or flutter run yourself.

Already have your Flutter project? You can get the sign-in out of the way first: run getstream init from inside the project before you prompt the agent, and it picks up the initialized project from there.

Checkpoint: build and run the app if the agent hasn't (flutter run). You should see a channel list, be able to open a channel and send a message, and be able to tap a reply to open the thread screen.

Skim Important Building Blocks if you want to understand what the agent wrote before you extend it.

Path B - Build it manually

Follow the steps below. Every code block is a complete file or an explicitly scoped edit - each one says which.

For AI assistants reading this page: append .md to any Stream docs URL for a clean Markdown version. A condensed index for the Flutter SDK is at https://getstream.io/chat/docs/sdk/flutter/llms.txt. Prefer these over parsing HTML.

Important Building Blocks

Three pieces, one mental model:

  • StreamChatClient - Low-level API client. Handles auth, websockets, and (once you attach stream_chat_persistence) offline storage. Created once, at app launch.
  • StreamChat - The widget that wraps your app. Owns the theme and the component overrides, and hands them plus the client to every Stream widget below it through StreamChat.of(context). Every Stream widget needs it as an ancestor.
  • UI components - StreamChannelListView for the list, StreamMessageListView + StreamMessageComposer for a conversation. Use them as-is, theme them, or swap individual slots via StreamComponentBuilders.

You'll get a working app with the default UI first, then theme it, then customize the avatar. Deeper customizations are linked at the end.

Prerequisites

  • Flutter 3.41 (or later) on the stable channel, with Dart 3.11 or later - required by the Stream Chat Flutter SDK. No Flutter yet? Follow the official install guide and confirm with flutter doctor; on an existing install, flutter channel stable && flutter upgrade
  • Android minSdk 24 and iOS deployment target 13.0 - required by the SDK's media plugins. A project scaffolded by current Flutter already meets both, so this only matters when adding Stream to an older app
  • Stream Chat Flutter SDK version 10.2.0 (or latest) - you add this in Step 2

Step 1 - Create the project

Human checkpoint: you create the project, not your agent. Run:

bash
1
2
flutter create --empty --org io.getstream.tutorial --project-name chat_tutorial --platforms android,ios chat_tutorial cd chat_tutorial

--empty scaffolds a minimal lib/main.dart, which Step 4 replaces wholesale anyway. You can use your IDE's new-Flutter-project flow instead - if you do, delete anything it generates under test/, since those tests exercise starter code Step 4 removes.

Agents: detect the project shape first (find . -maxdepth 2 -name "pubspec.yaml"). If a Flutter project already exists, work in place instead of scaffolding a second one. If there is no project, stop and ask the human to create it - don't scaffold it yourself.

Checkpoint:

bash
1
2
flutter --version # 3.41 or later flutter analyze # No issues found!

Step 2 - Add the SDK dependency

Add the UI package and the offline-storage package:

bash
1
flutter pub add stream_chat_flutter stream_chat_persistence

Your pubspec.yaml now has caret constraints on the current 10.x release:

yaml
1
2
3
4
5
6
dependencies: flutter: sdk: flutter stream_chat_flutter: ^10.2.0 stream_chat_persistence: ^10.2.0

Then pin the Android Gradle Plugin to the 8.x line in android/settings.gradle.kts:

kotlin
1
2
3
4
5
plugins { id("dev.flutter.flutter-plugin-loader") version "1.0.0" id("com.android.application") version "8.13.0" apply false id("org.jetbrains.kotlin.android") version "2.3.20" apply false }

Why the pin: Flutter's current template ships AGP 9, which the Android plugin ecosystem is still catching up to - on an unpinned project the build stops at cannot find symbol: class FilePickerPlugin. AGP 8.13 keeps every plugin in the tree on the same toolchain. Remove the pin once your plugins all build against AGP 9.

No iOS build changes are needed - a project scaffolded by current Flutter already targets iOS 13.0 and pulls Stream's native dependencies through Swift Package Manager, so there's no Podfile to edit.

Checkpoint:

bash
1
2
3
flutter pub get # resolves stream_chat_flutter 10.2.x flutter build apk --debug # ✓ Built .../app-debug.apk flutter build ios --no-codesign # ✓ Built .../Runner.app (macOS only)

Step 3 - Get your credentials

You need an API key and a user token, both belonging to your own Stream app. The getstream CLI provisions them in one flow. Run these from the chat_tutorial directory you created in Step 1 - the CLI stores project credentials there.

1. Install the CLI (skip if you did this in Path A):

bash
1
curl -fsSL https://getstream.io/cli.sh | bash

2. Initialize the project. This one command authenticates you, lets you create or select an organization and app, and writes the project credentials. New to Stream? The sign-in flow creates your organization. Already have an org or an app? It lets you pick them.

bash
1
getstream init

Human checkpoint: getstream init opens a browser to authenticate. Agents: run it, then ask the human to finish logging in before continuing. It's required first - env, token, and api commands fail with "stream project is not initialized" until it runs.

3. Write your API key into the Flutter project. This writes the public API key to dart_defines.json (which it adds to .gitignore for you) instead of hardcoding it in source. The API secret is never printed or written into the app. You read it via String.fromEnvironment('STREAM_API_KEY') in Step 4 and run the app with flutter run --dart-define-from-file=dart_defines.json.

bash
1
getstream env --target flutter

4. Mint a user token for a user in your app (never-expiring by default; add a TTL for production-like testing):

bash
1
2
getstream token tutorial_user getstream token tutorial_user --ttl 1d

5. (Optional) Seed a channel so your first launch isn't an empty list. Create the users first, then the channel:

bash
1
2
3
4
getstream api UpdateUsers --request '{"users":{"tutorial_user":{"id":"tutorial_user","name":"Tutorial User"},"alice":{"id":"alice","name":"Alice"}}}' getstream api GetOrCreateChannel --type messaging --id general \ --request '{"data":{"created_by_id":"tutorial_user","members":[{"user_id":"tutorial_user"},{"user_id":"alice"}]}}'

Checkpoint: dart_defines.json contains a STREAM_API_KEY entry, and you have a token printed by the CLI, both belonging to your own app. Keep the token for Step 4.

Fallback: tutorial demo credentials

Want to skip account setup entirely? These work against Stream's shared, pre-seeded tutorial environment:

  • API key - b67pax5b2wdq
  • User ID - tutorial-flutter
  • Token - eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZmx1dHRlciJ9.S-MJpoSwDiqyXpUURgO5wVqJ4vKlIVFLSEyrFYCOE1c

Swap in your own credentials from the CLI flow above before building anything real.

On these, launch with plain flutter run - not the --dart-define-from-file command shown later. That file only exists after getstream env, and passing the flag without it is a hard error.

Step 4 - Get a working app

Four files under lib/, each complete: the client setup, plus three screens - channel list, conversation, and thread - composed from Stream's widgets. Wiring them together yourself is what makes any one of them easy to swap later.

Start with lib/main.dart. It builds the client, turns on offline storage, connects the user, and puts StreamChat above the app.

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
import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'package:stream_chat_persistence/stream_chat_persistence.dart'; import 'channel_list_page.dart'; /// Credentials from Step 3. /// - API key: `getstream env --target flutter` writes it to `dart_defines.json`, /// passed in with `--dart-define-from-file` and read here. Falls back to the demo key. /// - User + token: set `userId` to the user you minted a token for and paste that /// token below - both must match, or the connection is rejected. Or keep the /// demo pair below as-is. const _envApiKey = String.fromEnvironment('STREAM_API_KEY'); const apiKey = _envApiKey == '' ? 'b67pax5b2wdq' : _envApiKey; const userId = 'tutorial-flutter'; const userName = 'Tutorial Flutter'; const userToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoidHV0b3JpYWwtZmx1dHRlciJ9.S-MJpoSwDiqyXpUURgO5wVqJ4vKlIVFLSEyrFYCOE1c'; Future<void> main() async { /// Offline support: channels and messages are cached on device, so the app /// opens with content even without a connection. Attach the persistence /// client *before* `connectUser` - attaching it afterwards does nothing for /// the current session. final client = StreamChatClient(apiKey, logLevel: Level.INFO) ..chatPersistenceClient = StreamChatPersistenceClient( logLevel: Level.INFO, connectionMode: ConnectionMode.regular, ); /// Development token from `getstream token`. In production, fetch the /// token from your backend after login - never hardcode secrets. await client.connectUser( User(id: userId, name: userName), userToken, ); runApp(MyApp(client: client)); } class MyApp extends StatelessWidget { const MyApp({super.key, required this.client}); /// The client created in `main`. Holds the connection and the local cache. final StreamChatClient client; Widget build(BuildContext context) { return MaterialApp( /// `StreamChat` must be an ancestor of every Stream widget. Putting it in /// `builder` keeps it above whatever `home` renders. builder: (context, child) => StreamChat(client: client, child: child), home: const ChannelListPage(), ); } }

Then lib/channel_list_page.dart, the entry screen. StreamChannelListController owns the query, pagination, and live updates; StreamChannelListView renders it.

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
import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'channel_page.dart'; /// Displays the channels the current user is a member of. class ChannelListPage extends StatefulWidget { const ChannelListPage({super.key}); State<ChannelListPage> createState() => _ChannelListPageState(); } class _ChannelListPageState extends State<ChannelListPage> { /// Queries channels the current user belongs to, newest activity first. /// The controller owns pagination and live updates. 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) { return Scaffold( backgroundColor: context.streamColorScheme.backgroundApp, appBar: const StreamChannelListHeader(), body: StreamChannelListView( controller: _listController, onChannelTap: (channel) => Navigator.of(context).push( MaterialPageRoute( /// `StreamChannel` scopes the tapped channel to the subtree and /// calls `watch()` on it, so `ChannelPage` needs no arguments. builder: (_) => StreamChannel( channel: channel, child: const ChannelPage(), ), ), ), ), ); } }

Next lib/channel_page.dart, a single conversation. This is where reactions, attachments, typing indicators, URL previews, and read state all come for free.

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
import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; import 'thread_page.dart'; /// Displays the messages inside a single channel. class ChannelPage extends StatelessWidget { const ChannelPage({super.key}); Widget build(BuildContext context) { return Scaffold( backgroundColor: context.streamColorScheme.backgroundApp, appBar: const StreamChannelHeader(), body: Column( children: <Widget>[ Expanded( /// Threads are opt-in: with no `threadBuilder` (and no /// `onThreadTap`), tapping a reply does nothing at all. child: StreamMessageListView( threadBuilder: (_, parentMessage) => ThreadPage(parent: parentMessage!), ), ), StreamMessageComposer(), ], ), ); } }

Finally lib/thread_page.dart. The threadBuilder above points here, and the SDK handles the navigation.

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
import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// Displays the replies to a single parent message. class ThreadPage extends StatefulWidget { const ThreadPage({super.key, required this.parent}); /// The message this thread hangs off. final Message parent; State<ThreadPage> createState() => _ThreadPageState(); } class _ThreadPageState extends State<ThreadPage> { /// Seeding the composer with `parentId` is what makes a sent message a /// thread reply instead of a new channel message. late final _composerController = StreamMessageComposerController( message: Message(parentId: widget.parent.id), ); void dispose() { _composerController.dispose(); super.dispose(); } Widget build(BuildContext context) { return Scaffold( backgroundColor: context.streamColorScheme.backgroundApp, appBar: StreamThreadHeader(parent: widget.parent), body: Column( children: <Widget>[ Expanded( child: StreamMessageListView(parentMessage: widget.parent), ), StreamMessageComposer( messageComposerController: _composerController, ), ], ), ); } }

Run it with the API key from Step 3:

bash
1
flutter run --dart-define-from-file=dart_defines.json

Checkpoint: the channel list loads with no errors in the console. Tap a channel -> the conversation opens. Send a message, long-press it for reactions, tap a reply to open a thread, and paste a link to see it unfurl. Type @ for the mention autocomplete and / for slash commands - both are wired into the composer already. Empty list but no error? Your user has no channels yet - seed one with the getstream api GetOrCreateChannel command from Step 3.

Screenshot showing the channel list

Optional - enable photo, camera, and voice attachments. The composer browses the device gallery in-app, which needs media access declared on both platforms.

Add the usage descriptions to ios/Runner/Info.plist:

xml
1
2
3
4
5
6
<key>NSCameraUsageDescription</key> <string>Used to capture photos for chat messages.</string> <key>NSPhotoLibraryUsageDescription</key> <string>Used to attach images to chat messages.</string> <key>NSMicrophoneUsageDescription</key> <string>Used to record voice messages.</string>

And the media permissions to android/app/src/main/AndroidManifest.xml, above <application>:

xml
1
2
3
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/> <uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/> <uses-permission android:name="android.permission.READ_MEDIA_VISUAL_USER_SELECTED"/>

Voice recording needs nothing extra on Android - the SDK's recorder plugin merges RECORD_AUDIO in for you.

Prefer not to declare media permissions at all? Hand the composer the system picker instead. It needs no Android permissions, at the cost of the in-app gallery grid:

dart
1
StreamMessageComposer(useSystemAttachmentPicker: true)

Step 5 - Theme the app

Theming works in two layers, and you rarely need more than the first:

  • Design tokens - a StreamTheme registered as a ThemeData extension. Give it a brand color and Stream derives its whole semantic palette from that swatch.
  • Per-widget overrides - a StreamChatThemeData passed to StreamChat.themeData, merged on top. Reach for this only when one component needs to differ.

Both go in MyApp. Replace the class in lib/main.dart:

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
class MyApp extends StatelessWidget { const MyApp({super.key, required this.client}); /// The client created in `main`. Holds the connection and the local cache. final StreamChatClient client; Widget build(BuildContext context) { /// One brand color per brightness. Stream derives its whole semantic /// palette from the swatch, so this single value restyles bubbles, /// sending indicators, unread badges, and the composer cursor. final brand = StreamColorSwatch.fromColor(Colors.green); final brandDark = StreamColorSwatch.fromColor( Colors.green, brightness: Brightness.dark, ); /// Per-widget override, merged on top of the derived palette. Reusing /// `brand.shade100` is what keeps the tiles and the message bubbles in /// the same green family. final customTheme = StreamChatThemeData( channelListItemTheme: StreamChannelListItemThemeData( titleStyle: const TextStyle(fontWeight: FontWeight.bold), backgroundColor: WidgetStateProperty.all(brand.shade100), ), ); return MaterialApp( theme: ThemeData( brightness: Brightness.light, extensions: [ StreamTheme( brightness: Brightness.light, colorScheme: StreamColorScheme.light(brand: brand), ), ], ), darkTheme: ThemeData( brightness: Brightness.dark, extensions: [ StreamTheme( brightness: Brightness.dark, colorScheme: StreamColorScheme.dark(brand: brandDark), ), ], ), builder: (context, child) => StreamChat( client: client, themeData: customTheme, child: child, ), home: const ChannelListPage(), ); } }

Checkpoint: the whole app turns green - outgoing bubbles, the sending indicator, unread badges, and the composer cursor all pick up the brand swatch, and channel tiles get the lighter tint with bold titles. Switch your device to dark mode and the dark swatch takes over.

Full theming reference: StreamChat and theming.

Screenshot showing the green brand theme applied

Step 6 - Customize one piece of the message list

Theming changes tokens (colors, fonts, shapes). To change an actual widget, register a component builder and override only the slot you want - every other widget keeps its default. Here we swap the circular avatar for a rounded square.

Avatar shape isn't a theme token, so this is a slot job rather than a Step 5 job.

Create lib/rounded_avatar.dart:

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
import 'package:flutter/material.dart'; import 'package:stream_chat_flutter/stream_chat_flutter.dart'; /// A rounded-square avatar to replace the SDK's circular one. class RoundedAvatar extends StatelessWidget { const RoundedAvatar({super.key, required this.props}); /// Everything the SDK would have used to draw the default avatar. final StreamAvatarProps props; Widget build(BuildContext context) { final imageUrl = props.imageUrl; final size = props.size?.value ?? StreamAvatarSize.lg.value; return ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(8)), child: SizedBox.square( dimension: size, child: ColoredBox( color: props.backgroundColor ?? context.streamColorScheme.backgroundApp, child: imageUrl == null ? Center(child: props.placeholder(context)) : Image.network( imageUrl, fit: BoxFit.cover, errorBuilder: (context, _, _) => Center(child: props.placeholder(context)), ), ), ), ); } }

Reusing props.placeholder keeps the SDK's initials fallback for users with no picture, and props.size keeps every call site's sizing intact.

Then register it on StreamChat in lib/main.dart - add the import and the componentBuilders argument:

dart
1
2
3
4
5
6
7
8
9
10
11
12
import 'rounded_avatar.dart'; // ... builder: (context, child) => StreamChat( client: client, themeData: customTheme, componentBuilders: StreamComponentBuilders( avatar: (context, props) => RoundedAvatar(props: props), ), child: child, ),

That's the whole pattern: build a widget that takes the slot's props, then hand it to StreamComponentBuilders. The same approach replaces the message item, the composer, attachments, and more.

avatar is one of the shared slots you pass directly. Chat-specific ones - messageItem, channelListItem, messageComposer - go through extensions: streamChatComponentBuilders(...) instead. The analyzer tells you when a slot belongs to the other list.

Checkpoint: avatars are rounded squares instead of circles. Because avatar is a single global slot, the change lands in the message rows, the channel list, and the headers at once - without touching any of those widgets.

Screenshot showing rounded-square avatars

Customize further - cookbooks

For larger changes, Stream has full cookbooks. Each rebuilds a screen from smaller pieces instead of styling the pre-built one.

Start here

Channel list

Chat / message view

Verify the whole build

bash
1
2
flutter analyze flutter run --dart-define-from-file=dart_defines.json

Human checkpoint: launching on a simulator or device and confirming the UI is a human step. Agents: run flutter analyze and build, then hand back to the human to run it. Confirm the full loop: channel list loads -> open a channel -> send a message -> long-press for reactions -> open a thread. Then enable airplane mode and reopen the app: the channel list and recent messages still render from the local cache.

Troubleshooting

  • stream project is not initialized - CLI onboarding not run. Run getstream init in the project directory first.
  • getstream env returns 401 - the CLI session expired. Run getstream login, then retry.
  • cannot find symbol: class FilePickerPlugin - an Android plugin in the dependency tree doesn't build against AGP 9 yet. Pin AGP to 8.13.0 in android/settings.gradle.kts (Step 2).
  • pub get resolves an older SDK than 10.2.0 - your Flutter is below 3.41, so pub falls back to an older release whose APIs don't match this tutorial. Run flutter upgrade on the stable channel.
  • Blank screen after launch - connectUser failed. Check the console; confirm the API key and token belong to the same app.
  • token is invalid / auth error - the key and the token belong to different apps, or the token expired. If you provisioned your own app, first check you launched with --dart-define-from-file=dart_defines.json - without it the app falls back to the demo key while still using your token, which fails exactly this way. Otherwise re-mint with getstream token <user_id> and confirm the API key matches.
  • Channel list is empty, no errors - the user has no channels. Seed one with getstream api GetOrCreateChannel (Step 3), and make sure the token user is in data.members.
  • Did not find the file passed to "--dart-define-from-file" - dart_defines.json doesn't exist because getstream env never ran. On the demo credentials, launch with plain flutter run - the code falls back to the demo key when the define is absent.
  • Tapping a reply does nothing - StreamMessageListView has no threadBuilder (or onThreadTap). Threads are opt-in; wire it as in Step 4.
  • StreamChat.of() called with a context that does not contain a StreamChat - a Stream widget is outside the StreamChat ancestor, or you used the same context that created it. Keep StreamChat in MaterialApp.builder.
  • Offline cache stays empty - chatPersistenceClient was assigned after connectUser. Attach it with the cascade before connecting (Step 4).
  • Attachment or voice button crashes on iOS - missing Info.plist usage descriptions. Add the keys from the Step 4 note.
  • Gallery tab offers "Allow access to your gallery" but system settings has no permission to grant - the READ_MEDIA_* permissions aren't in AndroidManifest.xml. Add them (Step 4 note) and the runtime prompt appears normally.

Next steps

Pricing is flexible with multiple tiers, including a free maker plan for hobby projects and small companies.

Video / audio room integration

For a complete social experience, Stream provides a Video & Audio calling Flutter SDK, that works seamlessly with our chat products. If you want to learn more on how to integrate video into your apps, please check our docs and our tutorials about video calling and livestreaming.

We also have a guide on how to integrate video with chat.

Final Thoughts

We have shown you how to build a fully featured in-app chat experience with Flutter, which includes reactions, threads, typing indicators, offline storage, URL previews, user presence and more. It's pretty crazy how APIs & Flutter components enable you to build chat in hours. On top of that you now know how easy it is to add your own theme to the app and even fully customize its key components.

The chat app we built uses Stream's edge network for optimal performance and scalability. Stream powers thousands of apps and over a billion end users. There are several price tiers available, including a free plan for development and a free maker plan.

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. And for fully custom experiences, stream_chat_flutter_core exposes the same state and controllers without any UI of its own.

Machine-readable resources

For AI agents and coding assistants working with this SDK:

  • CLI + skills: curl -fsSL https://getstream.io/cli.sh | bash, then getstream skills --universal and getstream skills stream-flutter --universal for the Flutter pack (use --claude instead for Claude Code). (Alternative: npx skills add GetStream/agent-skills -s stream.)
  • Provisioning: getstream init (auth + create/select org & app) -> getstream env --target flutter (API key to dart_defines.json) -> getstream token <user_id> (mint a token)
  • Data & config from the CLI: getstream api <Endpoint> --request '{...}' for users, channels, and messages
  • Flutter integration skill: invoke /stream-flutter in your agent for Flutter setup patterns; /stream-docs searches live SDK docs with citations
  • Docs index for LLMs: https://getstream.io/chat/docs/sdk/flutter/llms.txt (condensed)
  • Markdown endpoints: append .md to any docs URL for a clean, token-efficient version
  • Source of truth for APIs: the SDK repository - check the pinned version's source rather than assuming APIs from training data

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.