Telehealth is transforming the way patients and providers connect, offering faster access to care and reducing barriers caused by distance or scheduling. A critical part of this experience is enabling secure, real-time video consultations alongside features like chat messaging for sharing updates, questions, and follow-ups.
With Stream's healthcare chat solution, developers can build HIPAA-ready communication features into their apps.
In this tutorial, you'll learn how to clone MedTalk, a telehealth platform designed for seamless communication between doctors and patients. We'll use Flutter Web to deliver a responsive cross-platform experience and Stream's Chat & Video SDKs to power HIPAA-ready messaging and video calls.
Here’s a demo video of the complete application:
Prerequisites
-
A free Stream account and API key
-
Flutter SDK installed on your computer
Set Up Your Development Environment
To install Flutter on your computer, first open flutter.dev.
Note:
If you already have your development environment set up, you can clone the project and get started quickly.
git clone <https://github.com/Tabintel/medtech.git>
cd medtech
Run this command to install the dependencies needed for the app:
flutter pub get
If not, then continue with the steps below.
First, Download the version compatible with your computer’s operating system: Windows, macOS, Linux, or ChromeOS.
Then choose the type of app you’ll be building. For this tutorial, we’re building a Flutter web application, so select Web from the list.
After selecting the type of app you’re building, the next step is setting up the requirements to begin building with Flutter. Follow the steps in the Flutter documentation to proceed.
If you’re using another operating system, follow the guide for your OS.
Specific requirements for a Windows OS are:
- Use Git for Windows to manage Flutter versions and your source code versioning.
- Google Chrome to debug JavaScript code for web apps.
- Visual Studio Code with the Flutter extension for VS Code.
- Also, note that we are installing Flutter with VS Code.
- Once you meet all these requirements, you’re ready to start building.
Install the Flutter SDK
In your VS Code editor, run the Ctrl + Shift +p command.
Then select Download when this window shows.
After downloading and installing the Flutter SDK, you will choose the type of Flutter template you want to install.
For this project, select Application as shown below.
Now that we have the Flutter SDK, we can proceed to further develop the Virtual Health consultation platform with Stream and Flutter.
If you need any clarification, read the Flutter docs.
To confirm that you installed Flutter correctly, run flutter doctor -v in your command prompt terminal.
It should print out a similar response like so:
Follow these steps to set up the project for building with Flutter Web.
Create a medtech folder on your computer, and open it in the VS Code editor. In your command prompt, run:
flutter create . \--platforms web
The output then displays like so:
It creates the structure for the Flutter web project. It also makes a web/ directory containing the web assets used to bootstrap and run your Flutter app, like so:
In the following steps, we will look at how to set up Stream for the virtual health consultation platform with Flutter SDK.
Get Started with Stream
First, create an account in the Stream dashboard.
Then click on Create App to create a new application.
Enter the details of the application.
After creating the Stream app, go to your dashboard and click Chat Messaging in the top right. You will then see the App Access Keys displayed.
Create an .env file in your project’s root directory, copy the App key and secret, and save it like so:
12STREAM\_API\_KEY=your-api-key STREAM\_API\_SECRET=your-api-secret
Now you’re ready to build and integrate Stream using the Flutter SDK.
In your terminal, run this command:
1flutter pub get
This command installs all the dependencies listed in your pubspec.yaml, making packages like stream_chat_flutter, flutter_dotenv, available to your app. It’s required after any change to pubspec.yaml before you can use new packages in your code.
To integrate prebuilt UI components for chat in Flutter, use the stream_chat_flutter package. This package includes reusable and customizable UI components already integrated with Stream’s API.
Add the package to your dependencies like so:
12dependencies: stream\_chat\_flutter: ^1.0.1-beta
This package provides full chat UI widgets you can use directly in your app for fast integration.
For video, UI components to Stream Video in Flutter, check out the documentation.
Build the Role Selection Screen
As we develop the app, the codebase will increase to what is in the image below.
In the lib/screen/auth directory, create a file called role_selection_screen.dart and enter the code below:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081import 'package:flutter/material.dart'; import '../../stream\_client.dart'; class RoleSelectionScreen extends StatefulWidget { const RoleSelectionScreen({super.key}); @override State\<RoleSelectionScreen\> createState() \=\> \_RoleSelectionScreenState(); } class \_RoleSelectionScreenState extends State\<RoleSelectionScreen\> { bool \_loading \= false; Future\<void\> \_connectAndNavigate(String userId, String route, {String? name}) async { setState(() \=\> \_loading \= true); await StreamClientProvider.connectUser(userId, name: name); setState(() \=\> \_loading \= false); Navigator.pushNamed(context, route); } @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFFF7F9FC), body: Center( child: Padding( padding: const EdgeInsets.all(24.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: \[ Image.asset( 'assets/images/medtalk\_logo.png', height: 120, ), const SizedBox(height: 32), const Text( 'MedTalk', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: Color(0xFF2A4D9B)), ), const SizedBox(height: 8), const Text( 'Talk to a doctor anytime, anywhere', style: TextStyle(fontSize: 16, color: Color(0xFF4A4A4A)), textAlign: TextAlign.center, ), const SizedBox(height: 48), \_loading ? const CircularProgressIndicator() : Column( children: \[ ElevatedButton( onPressed: () \=\> \_connectAndNavigate('doctor', '/doctor', name: 'Dr. Sarah Lee'), style: ElevatedButton.styleFrom( minimumSize: const Size.fromHeight(48), backgroundColor: Color(0xFF2A4D9B), foregroundColor: Colors.white, textStyle: const TextStyle(fontWeight: FontWeight.bold), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: const Text('Continue as Doctor'), ), const SizedBox(height: 16), OutlinedButton( onPressed: () \=\> \_connectAndNavigate('patient', '/patient', name: 'Patient'), style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(48), foregroundColor: Color(0xFF2A4D9B), textStyle: const TextStyle(fontWeight: FontWeight.bold), side: const BorderSide(color: Color(0xFF2A4D9B), width: 2), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), ), child: const Text('Continue as Patient'), ), \], ), \], ), ), ), ); } }
Note: You can get the asset in 'assets/images/medtalk_logo.png' from this GitHub repository. Download the medtalk_logo.png file and add it to the /assets/images directory.
The role selection screen is the entry point to the virtual health consultation application. It allows users to choose between doctor and patient roles. This screen sets the user's identity and routes to the appropriate dashboard.
The state management uses Flutter's built-in StatefulWidget to manage UI states and tracks loading state with a _loading boolean to show/hide the loading indicator.
1234class \_RoleSelectionScreenState extends State\<RoleSelectionScreen\> { bool \_loading \= false; // ... }
We import flutter/material.dart for UI components and ../../stream_client.dart to manage connections to Stream Chat using a user’s ID and name.
The screen is built as a stateful widget to handle dynamic changes, such as displaying a loading spinner during Stream connection, with a _loading boolean (shown in the code snippet above) that tracks this connection status.
_connectAndNavigate function sets _loading to true, calls StreamClientProvider.connectUser(userId, name: name) to connect the user, then navigates to either /doctor or /patient once connected.
The UI has an off-white background, the MedTalk logo, and a tagline. If _loading is true, a CircularProgressIndicator is shown; otherwise, two buttons appear—“Continue as Doctor” (blue, connects with Dr. Sarah Lee) and “Continue as Patient” (outlined, connects with Patient).
This screen is the entry point to the virtual health consultation platform, guiding users with a personalized experience based on their role; Doctor or Patient.
Build the Doctor’s Home Screen
After users select their role and connect to Stream, doctors are taken to the DoctorHomeScreen. This is the main dashboard for doctors, where they can see demo patients, start chats, and view existing conversations.
In lib/screens/doctor/ create a file: doctor_home_screen.dart, and enter the code below:
The full code is in the GitHub repository.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104import 'package:flutter/material.dart'; import 'package:stream\_chat\_flutter/stream\_chat\_flutter.dart'; import '../../stream\_client.dart'; import '../../demo\_users.dart'; class DoctorHomeScreen extends StatefulWidget { const DoctorHomeScreen({Key? key}) : super(key: key); State\<DoctorHomeScreen\> createState() \=\> \_DoctorHomeScreenState(); } class \_DoctorHomeScreenState extends State\<DoctorHomeScreen\> { late final StreamChannelListController \_channelListController; void initState() { super.initState(); \_channelListController \= StreamChannelListController( client: StreamClientProvider.client, filter: Filter.in\_('members', \[StreamClientProvider.client.state.currentUser\!.id\]), limit: 20, ); } Widget build(BuildContext context) { final currentUser \= StreamClientProvider.client.state.currentUser; return Scaffold( backgroundColor: const Color(0xFFF7F9FC), appBar: AppBar( title: const Text('Doctor', style: TextStyle(color: Color(0xFF2A4D9B), fontWeight: FontWeight.bold)), backgroundColor: Colors.white, iconTheme: const IconThemeData(color: Color(0xFF2A4D9B)), elevation: 1, ), body: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: \[ // Patient list SizedBox( height: 120, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: demoPatients.length, separatorBuilder: (\_, \_\_) \=\> const SizedBox(width: 16), itemBuilder: (context, index) { final patient \= demoPatients\[index\]; return Card( elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Container( width: 200, padding: const EdgeInsets.all(12), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: \[ Text(patient.name, style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), ElevatedButton( onPressed: () async { final client \= StreamClientProvider.client; final currentUser \= client.state.currentUser; if (currentUser \== null) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Current user not found. Please log in again.')), ); return; } try { // Create the channel final channel \= client.channel( 'messaging', extraData: { 'members': \[currentUser.id, patient.id\], 'created\_by\_id': currentUser.id, }, ); // Create the channel on Stream await channel.create(); // Navigate to the chat if (\!mounted) return; Navigator.push( context, MaterialPageRoute( builder: (context) \=\> StreamChannel( channel: channel, child: const StreamChannelPage(), ), ), ); } catch (e) { if (\!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error creating chat: ${e.toString()}')), ); } }, child: const Text('Start Chat'), ), \], ), ), ); },
We import four files:
- flutter/material.dart for UI components.
- stream_chat_flutter.dart for prebuilt Stream Chat widgets and controllers.
- stream_client.dart for the connected Stream client instance.
- demo_users.dart for sample patient data, so the demo works without a real backend.
DoctorHomeScreen is a stateful widget because it manages a StreamChannelListController, which fetches channels based on filters. In initState(), the controller is created with:
- client: StreamClientProvider.client — the active Stream connection.
- filter: Filter.in_('members', [currentUser.id]) — returns only channels that include the logged-in doctor.
- limit: 20 — loads the latest 20 channels.
In the build method, we get the currentUser to display their info and use it when creating new chats. The UI has two main sections:
1. Patient List (top horizontal list)
A scrollable row of demo patients from demoPatients. Each patient card shows their name and a Start Chat button. It then:
- Creates a messaging channel with members [doctorId, patientId].
- Calls await channel.create() to set up the conversation in Stream.
- Navigates to StreamChannelPage, which shows the chat UI if there’s an error, and a SnackBar displays the message.
2. Chat List (main section)
A StreamChannelListView that automatically lists all the doctor’s existing chats based on the controller’s filter. Tapping a channel opens it in StreamChannelPage.
The StreamChannelPage
This uses three widgets from stream_chat_flutter:
- StreamChannelHeader(): shows the other user’s name and a back button.
- StreamMessageListView(): displays real-time messages.
- StreamMessageInput(): lets users send new messages with emoji and attachments.
Stream Chat’s Flutter SDK handles message syncing, typing indicators, history, and UI updates, so we don’t build these features manually.
This setup allows doctors to see their patients, start conversations, view existing chats, and message in real time. Because this is Flutter Web, you can even open two browser tabs: one as a doctor and one as a patient.
Next, we’ll switch to the patient’s view in lib/screens/patient/patient_home_screen.dart to see how the experience mirrors the doctor’s while keeping roles separate.
Build the Patient’s Home Screen
Just like the doctor’s view, our patient home screen gives users two main capabilities:
- See available doctors.
- Start or continue chats with them.
Create a patient_home_screen.dart file in the lib/screens/patient directory and enter the code below:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101import 'package:flutter/material.dart'; import 'package:stream\_chat\_flutter/stream\_chat\_flutter.dart'; import '../../stream\_client.dart'; import '../../demo\_users.dart'; class PatientHomeScreen extends StatefulWidget { const PatientHomeScreen({Key? key}) : super(key: key); State\<PatientHomeScreen\> createState() \=\> \_PatientHomeScreenState(); } class \_PatientHomeScreenState extends State\<PatientHomeScreen\> { late final StreamChannelListController \_channelListController; void initState() { super.initState(); \_channelListController \= StreamChannelListController( client: StreamClientProvider.client, filter: Filter.in\_('members', \[StreamClientProvider.client.state.currentUser\!.id\]), limit: 20, ); } Widget build(BuildContext context) { final currentUser \= StreamClientProvider.client.state.currentUser; return Scaffold( backgroundColor: const Color(0xFFF7F9FC), appBar: AppBar( title: const Text('Patient', style: TextStyle(color: Color(0xFF2A4D9B), fontWeight: FontWeight.bold)), backgroundColor: Colors.white, iconTheme: const IconThemeData(color: Color(0xFF2A4D9B)), elevation: 1, ), body: Padding( padding: const EdgeInsets.all(8.0), child: Column( children: \[ // Doctor list SizedBox( height: 120, child: ListView.separated( scrollDirection: Axis.horizontal, itemCount: demoDoctors.length, separatorBuilder: (\_, \_\_) \=\> const SizedBox(width: 16), itemBuilder: (context, index) { final doctor \= demoDoctors\[index\]; return Card( elevation: 2, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: Container( width: 200, padding: const EdgeInsets.all(12), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: \[ Text(doctor.name, style: const TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 8), ElevatedButton( onPressed: () async { final client \= StreamClientProvider.client; final currentUser \= client.state.currentUser; if (currentUser \== null) { if (\!mounted) return; ScaffoldMessenger.of(context).showSnackBar( const SnackBar(content: Text('Error: User not logged in')), ); return; } try { // Create the channel final channel \= client.channel( 'messaging', extraData: { 'members': \[currentUser.id, doctor.id\], 'created\_by\_id': currentUser.id, }, ); // Create the channel on Stream await channel.create(); // Navigate to the chat if (\!mounted) return; Navigator.push( context, MaterialPageRoute( builder: (context) \=\> StreamChannel( channel: channel, child: const StreamChannelPage(), ), ), ); } catch (e) { if (\!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Error creating chat: ${e.toString()}')), ); } }, child: const Text('Start Chat'), ),
Get the full code in GitHub.
When the screen loads, initState() creates a StreamChannelListController that acts as the inbox manager. It connects to StreamClientProvider.client (set up in role_selection_screen.dart) and filters for any channels where the logged-in patient is a member:
1filter: Filter.in\_('members', \[StreamClientProvider.client.state.currentUser\!.id\]),
This works because the patient is connected to Stream through getstream-token-server.js. (We will explain this later in the article.)
The Node server takes the patient’s ID ("patient"), uses the Stream API key and secret from .env, generates a user token, and registers them with Stream. Without that step, currentUser would be null and chats wouldn’t work.
The UI starts with a horizontal list of doctors pulled from demoDoctors.
When a patient taps Start Chat:
- We create or retrieve a messaging channel with both patient and doctor IDs.
- If it doesn’t exist, Stream creates it automatically.
- We call await channel.create() to finalize it.
- We navigate to StreamChannelPage, which uses StreamMessageListView() for real-time messages and StreamMessageInput() for sending new ones.
At the bottom, StreamChannelListView displays all active chats, including ones started by doctors. Stream’s SDK updates this list instantly, so new messages appear in real time.
With both doctor and patient home screens complete, we have a two-way chat system using Flutter Web, Stream Flutter SDK, and a Node token server. The next step is to add a shared chat screen functionality.
Build the Shared Chat Screen
Both doctors and patients use the shared chat screen. It provides a reusable UI for a single channel, powered by the Stream Chat Flutter SDK. You pass in a channelId and userId, and the screen connects to that channel, showing real-time messages with Stream’s prebuilt widgets.
Create a chat_screen.dart file in the lib/screens/shared directory and enter the code below:
1234567891011121314151617181920212223242526272829303132333435363738import 'package:flutter/material.dart'; import 'package:stream\_chat\_flutter/stream\_chat\_flutter.dart'; import '../../stream\_client.dart'; class ChatScreen extends StatefulWidget { final String channelId; final String userId; const ChatScreen({super.key, required this.channelId, required this.userId}); State\<ChatScreen\> createState() \=\> \_ChatScreenState(); } class \_ChatScreenState extends State\<ChatScreen\> { late Channel channel; void initState() { super.initState(); channel \= StreamClientProvider.client.channel( 'messaging', id: widget.channelId, extraData: {'members': \[widget.userId\]}, ); channel.watch(); } Widget build(BuildContext context) { return StreamChannel( channel: channel, child: Scaffold( appBar: StreamChannelHeader(), body: Column( children: \[ Expanded(child: StreamMessageListView()), StreamMessageInput(), \], ), ), ); } }
When the widget loads, initState() creates a Channel from the global client in StreamClientProvider.
It calls:
12345client.channel( 'messaging', id: widget.channelId, extraData: {'members': \[widget.userId\]}, )
This communicates with Stream to:
“Get or create this messaging channel with these members.”
If the channel doesn’t exist yet, Stream will create it when channel.create() is called earlier in the flow. The extraData helps identify channel members and supports filtering and admin logic.
After creating the channel object, the code calls channel.watch(). This starts streaming channel state and events—messages, typing indicators, presence updates, and reactions—over a WebSocket connection. Updates arrive instantly without polling.
In the build() method, the UI is wrapped in StreamChannel(channel: channel, child: ...). StreamChannel is a provider widget that makes the channel available to its children. Inside, the prebuilt widgets handle the chat experience:
- StreamChannelHeader() → shows the channel name and back button.
- StreamMessageListView() → displays the live message feed with avatars, ordering, and read receipts.
- StreamMessageInput() → sends messages, attachments, and reactions.
These widgets handle sending and receiving messages, rendering updates, retry logic, and syncing with Stream’s servers.
This screen assumes the user is already connected to Stream through StreamClientProvider—a step that happens earlier (for example, in role_selection_screen.dart). That connection involves calling your Node-based token server (getstream-token-server.js), which uses your API secret to generate a user token. The Flutter app then calls client.connectUser(User(id: ...), token) to authenticate.
When a doctor or patient clicks “Start Chat” from their home screen, the app either creates the channel there or navigates into this ChatScreen with the existing channelId. As long as both members are included in the channel’s members list, Stream will route messages correctly and store them in your dashboard.
Connect to Stream Client
To connect the Flutter app to the Stream client, create a stream.dart file in the lib/stream directory and enter the code below:
1234567891011121314151617181920212223242526import 'package:stream\_chat\_flutter/stream\_chat\_flutter.dart'; import 'package:flutter\_dotenv/flutter\_dotenv.dart'; import 'dart:convert'; import 'package:http/http.dart' as http; class StreamClientProvider { static final StreamChatClient client \= StreamChatClient( dotenv.env\['STREAM\_API\_KEY'\]\!, logLevel: Level.INFO, ); /// Connect a user to the Stream Chat client (production, fetch token from backend) static Future\<void\> connectUser(String userId, {String? name}) async { if (client.state.currentUser?.id \== userId) return; await client.disconnectUser(); // Fetch token from backend final tokenEndpoint \= dotenv.env\['TOKEN\_ENDPOINT'\] ?? 'http://localhost:3000/token'; final response \= await http.get(Uri.parse('$tokenEndpoint?user\_id=$userId')); if (response.statusCode \!= 200\) { throw Exception('Failed to fetch Stream Chat token: ${response.body}'); } final token \= jsonDecode(response.body)\['token'\]; await client.connectUser( User(id: userId, name: name ?? userId), token, ); } }
This connects the Flutter app and the Stream Chat API. It loads your API key from .env, initializes a single Stream client for the whole app, and connects users by fetching their token from your backend.
At the top, we import:
1234import 'package:stream\_chat\_flutter/stream\_chat\_flutter.dart'; import 'package:flutter\_dotenv/flutter\_dotenv.dart'; import 'dart:convert'; import 'package:http/http.dart' as http;
- stream_chat_flutter – The official Flutter SDK for Stream Chat.
- flutter_dotenv – Loads environment variables from .env, such as your API key and backend URL.
- dart:convert – Decodes JSON responses from the server.
- http – Makes HTTP requests to your backend to get the chat token.
The main class is StreamClientProvider, which creates one StreamChatClient for the entire app:
1234static final StreamChatClient client \= StreamChatClient( dotenv.env\['STREAM\_API\_KEY'\]\!, logLevel: Level.INFO, );
Here, the API key comes from .env, and the log level is set to INFO so you can see helpful debug messages about chat events.
The connectUser method is where the actual login happens:
123static Future\<void\> connectUser(String userId, {String? name}) async { if (client.state.currentUser?.id \== userId) return; await client.disconnectUser();
First, it checks if the user is already connected with the same ID. If so, it skips the rest. If not, it disconnects any existing users to avoid conflicts.
Next, it fetches the token from your backend:
123final tokenEndpoint = dotenv.env['TOKEN_ENDPOINT'] ?? 'http://localhost:3000/token'; final response = await http.get(Uri.parse('$tokenEndpoint?user_id=$userId'));
The token endpoint is read from .env (falling back to localhost for development). The request includes the user ID as a query parameter.
If the server responds with anything other than 200 OK, it throws an error. Otherwise, it extracts the token from the JSON:
123456if (response.statusCode != 200) { throw Exception( 'Failed to fetch Stream Chat token: ${response.body}', ); } final token = jsonDecode(response.body)['token'];
Finally, it connects the user to Stream:
1234await client.connectUser( User(id: userId, name: name ?? userId), token, );
The SDK uses this ID, optional display name, and the JWT token from your backend to log in securely. The token is generated server-side using your API key and secret, so those sensitive credentials never touch the Flutter app.
Basically, the stream_client.dart file loads your API key from .env, requests a secure token from the server, and connects the user to Stream Chat so they can send and receive messages in real time.
Set Up the Main App Entry Point
In the lib directory, create a main.dart file. This will serve as the entry point for your Flutter application.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647import 'package:flutter/material.dart'; import 'package:flutter\_dotenv/flutter\_dotenv.dart'; import 'package:stream\_chat\_flutter/stream\_chat\_flutter.dart'; import 'package:flutter\_localizations/flutter\_localizations.dart'; import 'stream\_client.dart'; import 'screens/auth/role\_selection\_screen.dart'; import 'screens/doctor/doctor\_home\_screen.dart'; import 'screens/patient/patient\_home\_screen.dart'; import 'screens/shared/video\_call\_screen.dart'; Future\<void\> main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: '.env'); runApp(MedTalkApp()); } class MedTalkApp extends StatelessWidget { MedTalkApp({super.key}); final StreamChatClient client \= StreamClientProvider.client; Widget build(BuildContext context) { return MaterialApp( title: 'MedTalk', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue), useMaterial3: true, ), localizationsDelegates: \[ GlobalMaterialLocalizations.delegate, GlobalWidgetsLocalizations.delegate, GlobalCupertinoLocalizations.delegate, \], supportedLocales: \[ Locale('en'), \], initialRoute: '/', routes: { '/': (context) \=\> const RoleSelectionScreen(), '/doctor': (context) \=\> const DoctorHomeScreen(), '/patient': (context) \=\> const PatientHomeScreen(), '/video': (context) \=\> const VideoCallScreen(participantName: 'Dr. Sarah Lee'), }, builder: (context, child) \=\> StreamChat( client: client, child: child\!, ), ); } }
We start by importing the required packages:
- flutter/material.dart for core Flutter UI components
- flutter_dotenv to load environment variables from the .env file
- stream_chat_flutter for integrating the Stream Chat SDK
- flutter_localizations to add localization support
- stream_client.dart` (local file) and screen widgets for different roles and features.
Next, we set up the main() function, which ensures Flutter’s bindings are initialized, loads our environment variables, and finally runs the MedTalkApp.
The MedTalkApp widget extends StatelessWidget and defines the app’s overall configuration. It creates a StreamChatClient instance (from StreamClientProvider.client) and sets up the app theme, localization delegates, and supported locales.
For navigation, we define our initial route ('/'), which takes the user to the Role Selection Screen, and register other routes for the Doctor Home, Patient Home, and Video Call screens. This makes navigating between different app parts easy, depending on the user’s role and actions.
The builder property of MaterialApp wraps the child widget with the StreamChat widget, passing it on to the client. This ensures the Stream Chat SDK is available throughout the app’s widget tree, enabling messaging and chat features in any screen.
With the app’s entry point ready, the next step is to create a node server to generate Stream Chat tokens.
Set Up the Node.js Server
Create a getstream-token-server.js file in the root directory and enter the code below:
Get the full code in the GitHub repository.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394// Express server to generate Stream Chat JWT tokens const express \= require('express'); const StreamChat \= require('stream-chat').StreamChat; const cors \= require('cors'); require('dotenv').config(); console.log('Starting token server...'); console.log('Environment variables loaded:', { STREAM\_API\_KEY: process.env.STREAM\_API\_KEY ? '\*\*\* (set)' : 'MISSING', STREAM\_API\_SECRET: process.env.STREAM\_API\_SECRET ? '\*\*\* (set)' : 'MISSING', PORT: process.env.PORT || '3000 (default)' }); const app \= express(); const port \= process.env.PORT || 3000; // Load Stream credentials from environment variables const apiKey \= process.env.STREAM\_API\_KEY; const apiSecret \= process.env.STREAM\_API\_SECRET; if (\!apiKey || \!apiSecret) { console.error('ERROR: STREAM\_API\_KEY and STREAM\_API\_SECRET must be set in .env'); process.exit(1); } try { const serverClient \= StreamChat.getInstance(apiKey, apiSecret); console.log('Stream Chat client initialized successfully'); app.use(cors()); app.use(express.json()); // Request logging middleware app.use((req, res, next) \=\> { console.log(\`\[${new Date().toISOString()}\] ${req.method} ${req.url}\`); next(); }); app.get('/token', (req, res) \=\> { console.log('Token request received:', req.query); const userId \= req.query.user\_id; if (\!userId) { console.error('No user\_id provided'); return res.status(400).json({ error: 'user\_id query parameter is required' }); } try { const token \= serverClient.createToken(userId); console.log(\`Token generated for user: ${userId}\`); res.json({ token }); } catch (error) { console.error('Error generating token:', error); res.status(500).json({ error: 'Failed to generate token', details: error.message }); } }); app.get('/', (req, res) \=\> { res.send(\` \<h1\>Stream Chat Token Server\</h1\> \<p\>Server is running. Use /token?user\_id=USER\_ID to get a token.\</p\> \<p\>Example: \<a href="/token?user\_id=doctor\_anna"\>Get token for doctor\_anna\</a\>\</p\> \`); }); // Error handling middleware app.use((err, req, res, next) \=\> { console.error('Server error:', err); res.status(500).json({ error: 'Internal server error', details: err.message }); }); const server \= app.listen(port, '0.0.0.0', () \=\> { const address \= server.address(); console.log('\\n--- Server Info \---'); console.log(\`Server running at:\`); console.log(\`- Local: http://localhost:${address.port}\`); console.log(\`- Network: http://${require('os').hostname()}:${address.port}\`); console.log(\`- Network: http://${getLocalIpAddress()}:${address.port}\`); console.log('-------------------\\n'); console.log('Test endpoints:'); console.log(\`- http://localhost:${address.port}/token?user\_id=doctor\_anna\`); console.log('\\nPress Ctrl+C to stop the server\\n'); }); // Handle process termination process.on('SIGINT', () \=\> { console.log('\\nShutting down server...'); server.close(() \=\> { console.log('Server stopped'); process.exit(0); }); }); } catch (error) { console.error('Failed to initialize server:', error); process.exit(1); } // Helper function to get local IP address function getLocalIpAddress() { const interfaces \= require('os').networkInterfaces(); for (const name of Object.keys(interfaces)) { for (const iface of interfaces\[name\]) { if ('IPv4' \=== iface.family && \!iface.internal) { return iface.address; } } } return 'localhost'; }
This runs a Node.js + Express server that generates secure JWT tokens for authenticating users with the Stream Chat API.
Note: In production, do not hardcode your Stream API secret directly in your Flutter app. Instead, the server/backend generates a timed token for the authenticated user, and your Flutter app uses that token to connect to Stream. This is exactly what this Node token server does in this context.
We start by importing the required packages:
- express to create a simple HTTP server
- stream-chat to talk to Stream’s API and create tokens
- cors so our Flutter web app can call the server without CORS issues
- dotenv so we can store our API key and secret safely in a .env file
Next, we load the environment variables and check if the STREAM_API_KEY and STREAM_API_SECRET are set. The server will stop immediately if they're missing; we can’t run without them.
We then initialize a StreamChat server client using the API key and secret. This client will be able to create tokens, seed users, and perform admin operations.
The server exposes two main endpoints:
- /token?user_id=USER_ID – This generates a JWT token for the given user_id. Flutter will call this when connecting to Stream.
- / – A simple HTML page just to verify that the server is running.
We also added:
- Request logging so you can see when tokens are requested.
- A user seeding script (seedUsers) that adds demo doctors and patients to Stream. These IDs and names match the ones in our Flutter app, so you can test right away.
- Graceful shutdown handling so the server closes cleanly when you stop it.
Open a terminal and start the server with this command:
node getstream-token-server.js
Now your Flutter app can request tokens securely and connect to Stream Chat.
Set Up Demo Users for MedTalk
To keep development simple, we’ll define a set of demo doctors and patients that our MedTalk app can use for testing. This will allow us to quickly switch between roles (doctor or patient).
In the lib directory, create a new file called demo_users.dart and add the following:
1234567891011121314151617class DemoUser { final String id; final String name; final String role; // 'doctor' or 'patient' final String? avatarUrl; const DemoUser({required this.id, required this.name, required this.role, this.avatarUrl}); } const demoDoctors \= \[ DemoUser(id: 'doctor\_anna', name: 'Dr. Anna Smith', role: 'doctor'), DemoUser(id: 'doctor\_john', name: 'Dr. John Lee', role: 'doctor'), DemoUser(id: 'doctor\_emily', name: 'Dr. Emily Wong', role: 'doctor'), \]; const demoPatients \= \[ DemoUser(id: 'patient\_bob', name: 'Bob Brown', role: 'patient'), DemoUser(id: 'patient\_jane', name: 'Jane Doe', role: 'patient'), DemoUser(id: 'patient\_mike', name: 'Mike Green', role: 'patient'), \];
With this setup, we can easily pull a list of doctors or patients in the UI, making it much faster to test the application.
Now the full Flutter app is set up, and we can begin testing it!
Test the Virtual Health Consultation App
Select Chrome as your app's target device to run and debug a Flutter web app:
1flutter run \-d chrome
You can also choose Chrome as a target device in your IDE.
If you prefer, you can use the edge device type on Windows, or use web-server to navigate to a local URL in the browser of your choice.
Run From the Command Line
If you use Flutter run from the command line, you can now run hot reload on the web with the following command:
1flutter run \-d chrome \--web-experimental-hot-reload
When hot reload is enabled, you can restart your application by pressing "r" in the running terminal or "R".
The Flutter web app loads and opens this URL in the Chrome browser:
This is the application's landing page.
When you click on either Continue as Doctor or Continue as Patient, it then redirects to a loading screen for a few seconds, like so:
Which then redirects to the chat page, with this URL (if you selected Patient role), like so:
http://localhost:57331/\#/patient
At the moment, there are no active chats yet.
The users then show:
Then the patients:
And the Patient/Doctor chat loads:
From the Stream dashboard, check the chat logs to confirm the application is integrated with Stream through the Flutter SDK.
Note: We built a Node.js server for handling the Stream tokens for chat/video.
Test the endpoint in your browser or with curl:
You should get a JSON response like:
How to Run the Flutter Web App
To connect the Flutter web app with the Stream Flutter SDK, we set up a server using Node.js, as shown in the step above. This server generates tokens from Stream Chat using the Stream API, which is connected through the API key set up in the .env file at the root of the project directory codebase.
To do this, we first need to run the Node.js server, seed some users (Doctors and Patients), and assign Stream tokens to each user. This enables users to chat asynchronously on the platform.
To get started running the app, first run the Node.js server in a different terminal.
Note: We use two command prompts in a terminal to run this application.
In the first terminal, run this command:
1node getstream-token-server.js
This starts the node server and shows an output like so:
To test an endpoint, open this URL in your browser: http://localhost:3000/token?user_id=doctor_anna
It then loads this HTML page, and when you click Get token for doctor anna.
It generates a token like so:
You can also view the server logs in the Node.js terminal.
The token server is now up and running perfectly with this setup:
- Server is listening on http://localhost:3000
- It has successfully generated a token for a Doctor user, doctor_anna
- All demo users are seeded in Stream Chat
Next, let's test the Flutter app, which should now be able to:
- Connect to the token server
- Authenticate users
- Create chat channels
Run the Flutter app with this command:
1flutter run \-d chrome
The Flutter app begins to start and shows these logs in your terminal. If you look closely at the log messages, you will see that the Flutter app is connected to the Stream API.
The Flutter app then loads and opens in your Chrome browser.
Click on either Continue as Doctor or Patient, and then try starting a new chat. If you select the Doctor role, the chat loads, showing the history of previous chats with Patients.
Click on any user, say Bob Brown, to start a chat
You can also confirm that the messages are sent through Stream by viewing the real-time chat logs in your dashboard.
Now let’s try out the Patient's chat!
Navigate to the homepage of the application with http://localhost:64150, and then click on Continue as Patient, which then loads the view as shown below:
The direct URL to the patient view of the app is: http://localhost:64150/#/patient
Click on any of the Doctors, say Doctor Emily, to start a conversation:
- As a Doctor:
- Click "Start Chat" with a patient
- Send a message in the chat
- The chat should now appear in your chat list
- As a Patient:
- Log in as the patient you chatted with
- You should see the chat in your chat list
- You can reply, and the doctor will see it immediately
- Verify Real-time Sync:
- Open the same chat on two devices (or two different user accounts)
- Messages should appear instantly on both devices when sent from either side
You can view the total number of messages on the Stream chat dashboard.
To run the app locally, clone the GitHub repository with this command.
1git clone
Note: As mentioned in the article earlier, make sure you have Flutter set up and installed on your computer.
Navigate to the project directory, then run this command to install the dependencies needed for the app.
1flutter pub get
Also, ensure your Stream API keys are added to an .env file in the project directory.
Then run the application with:
1flutter run \-d chrome
And that’s it! You have a working virtual consultation app, fully set up
Conclusion
In this tutorial, you built a MedTalk clone, a HIPAA-compliant telehealth application powered by Flutter and Stream. By integrating Stream Chat and Stream Video, you enabled secure messaging and real-time video consultations between doctors and patients. This prototype shows how quickly you can deliver compliant, modern healthcare communication experiences with Stream’s SDKs.
