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:
flutter create chat_app
cd chat_app3. pubspec.yaml (exact dependencies):
name: chat_app
dependencies:
flutter:
sdk: flutter
stream_chat_flutter: ^9.18.0
stream_chat_flutter_core: ^9.18.04. Run:
flutter pub getBuilding 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
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
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)
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)
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)
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)
