Building a Flutter chat app with the Stream SDK starts with understanding how a reliable messaging layer shapes the entire user experience. Real-time communication needs a structure that stays consistent, responds quickly, and handles active conversations without breaking. Flutter gives the flexibility to craft the interface, while Stream provides the foundation that keeps messages flowing smoothly. Bringing these two together creates a setup where design and functionality move in sync, making the chat experience both stable and easy to maintain.

Prerequisites

1. System Requirements:

  • Flutter 3.26+ (flutter doctor --verbose)
  • Android Studio/VS Code + Flutter/Dart extensions
  • iOS simulator or Android emulator/device
  • Stream dashboard account (api_key)

2. Create Project:

code
flutter create chat_app
cd chat_app

3. pubspec.yaml (exact dependencies):

code
name: chat_app
dependencies:
flutter:
sdk: flutter
stream_chat_flutter: ^9.18.0
stream_chat_flutter_core: ^9.18.0

4. Run:

code
flutter pub get

Building a Flutter Chat App With The Stream SDK

When this part of the app comes together, the goal is to connect the visual structure you build in Flutter with the real-time message handling provided by Stream, ensuring that the chat features remain consistent, responsive, and ready for active conversations.

main.dart

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

void main() async {
WidgetsFlutterBinding.ensureInitialized();

final client = StreamChatClient(
'YOUR_API_KEY_HERE', // From Stream dashboard
logLevel: Level.INFO,
);

runApp(ChatApp(client: client));
}

class ChatApp extends StatelessWidget {
final StreamChatClient client;
const ChatApp({super.key, required this.client});

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Stream Chat Demo',
theme: ThemeData(
useMaterial3: true,
primarySwatch: Colors.blue,
),
builder: (context, child) => StreamChat(
client: client,
streamChatThemeData: StreamChatThemeData.fromTheme(Theme.of(context)),
child: child!,
),
home: const LoginScreen(),
);
}
}

Critical: StreamChat wrapper provides client context to all screens.​

Login Screen (login_screen.dart) - Secure Auth

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

class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});

@override
State<LoginScreen> createState() => _LoginScreenState();
}

class _LoginScreenState extends State<LoginScreen> {
final _usernameController = TextEditingController();
bool _isLoading = false;
String? _error;

Future<void> _login() async {
if (_usernameController.text.trim().isEmpty) return;

setState(() {
_isLoading = true;
_error = null;
});

try {
final client = StreamChat.of(context).client;
await client.connectUser(
User(
id: _usernameController.text.trim(),
name: _usernameController.text.trim(),
),
client.devToken(_usernameController.text.trim()), // Production: JWT
);

if (mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const ChannelListScreen()),
);
}
} on StreamChatException catch (e) {
setState(() => _error = e.message);
} catch (e) {
setState(() => _error = 'Connection failed: $e');
} finally {
if (mounted) setState(() => _isLoading = false);
}
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Login')),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _usernameController,
decoration: InputDecoration(
labelText: 'Username',
errorText: _error,
border: const OutlineInputBorder(),
),
enabled: !_isLoading,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _isLoading ? null : _login,
child: _isLoading
? const SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Connect'),
),
if (_error != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(_error!, style: const TextStyle(color: Colors.red)),
),
],
),
),
);
}
}

Channel List (channel_list_screen.dart)

code
import 'package:flutter/material.dart';
import 'package:stream_chat_flutter_core/stream_chat_flutter_core.dart';
import 'package:stream_chat_flutter/stream_chat_flutter.dart';
import 'channel_screen.dart';
import 'login_screen.dart';

class ChannelListScreen extends StatelessWidget {
const ChannelListScreen({super.key});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Channels'),
actions: [
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => _logout(context),
),
],
),
body: ChannelsBloc(
child: ChannelListView(
filter: Filter.in_(
'members',
[StreamChat.of(context).currentUser!.id],
),
sort: const [
SortOption('last_message_at', direction: SortOption.DESC),
],
limit: 20,
channelWidget: const ChannelTile(),
emptyBuilder: (context, createChannel) => Center(
child: ElevatedButton(
onPressed: createChannel,
child: const Text('Create General Channel'),
),
),
onChannelTap: (channel) => Navigator.of(context).push(
MaterialPageRoute(
builder: (_) => ChannelScreen(channel: channel),
),
),
),
),
);
}

Future<void> _logout(BuildContext context) async {
final client = StreamChat.of(context).client;
await client.disconnectUser();
if (context.mounted) {
Navigator.pushReplacement(
context,
MaterialPageRoute(builder: (_) => const LoginScreen()),
);
}
}
}

Chat Screen (channel_screen.dart)

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

class ChannelScreen extends StatelessWidget {
final Channel channel;
const ChannelScreen({super.key, required this.channel});

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: const StreamChannelHeader(),
body: StreamChannel(
channel: channel,
child: Column(
children: [
Expanded(
child: StreamMessageListView(
threadBuilder: (context, details, messages) {
return ThreadPage(
parentMessage: details.parentMessage!,
);
},
),
),
const StreamMessageInput(),
],
),
),
);
}
}

Thread Page (thread_page.dart)

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

class ThreadPage extends StatefulWidget {
final Message parentMessage;
const ThreadPage({super.key, required this.parentMessage});

@override
State<ThreadPage> createState() => _ThreadPageState();
}

class _ThreadPageState extends State<ThreadPage> {
late final StreamMessageInputController _controller;

@override
void initState() {
super.initState();
_controller = StreamMessageInputController(
parentMessage: widget.parentMessage,
);
}

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

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: StreamThreadHeader(parent: widget.parentMessage),
body: Column(
children: [
Expanded(child: StreamMessageListView(parentMessage: widget.parentMessage)),
StreamMessageInput(messageInputController: _controller),
],
),
);
}
}

Run and Test the App

Running & testing the app launches it to check if everything works as expected, from login to sending messages. This step helps spot problems early and ensures the app is ready for real end-users without surprises.

Run the App: flutter run on 2 phones/emulators

Login: User1 types name → "Connect" → See channels

Create Chat: Tap "Create General Channel" → User1 joins

Real-time Test: Login User2 → Send messages → Both see instantly

Send Extras: Text + photo → Long-press message → Reply in thread

Logout: Tap logout → Back to login (no errors)