DEV Community

Cover image for Clone MedTalk: HIPAA-Ready Video and Chat Consultations in Flutter
Ekemini Samuel
Ekemini Samuel

Posted on • Originally published at getstream.io

Clone MedTalk: HIPAA-Ready Video and Chat Consultations in Flutter

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

Set Up Your Development Environment

To install Flutter on your computer, first open flutter.dev.

Flutter

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
Enter fullscreen mode Exit fullscreen mode

Run this command to install the dependencies needed for the app:

flutter pub get
Enter fullscreen mode Exit fullscreen mode

If not, then continue with the steps below.

First, download the version compatible with your computer’s operating system: Windows, macOS, Linux, or ChromeOS.

App

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.

Code

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:

Install the Flutter SDK

In your VS Code editor, run the Ctrl + Shift +p command.

Flutter

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.

Code

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:

Code

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:

project

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:

App

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.

Stream

Then click on Create App to create a new application.

App

Enter the details of the application.

App

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.

App

Create an .envfile in your project’s root directory, copy the App key and secret, and save it like so:

STREAM\_API\_KEY=your-api-key  
STREAM\_API\_SECRET=your-api-secret
Enter fullscreen mode Exit fullscreen mode

Now you’re ready to build and integrate Stream using the Flutter SDK.

In your terminal, run this command:

flutter pub get
Enter fullscreen mode Exit fullscreen mode

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.

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:

dependencies:  
  stream\_chat\_flutter: ^1.0.1-beta
Enter fullscreen mode Exit fullscreen mode

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.

App

In the lib/screen/auth directory, create a file called role_selection_screen.dart and enter the code below:

import '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'),  
                        ),  
                      \],  
                    ),  
            \],  
          ),  
        ),  
      ),  
    );  
  }  
}
Enter fullscreen mode Exit fullscreen mode

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 the loading state with a _loading boolean to show/hide the loading indicator.

class \_RoleSelectionScreenState extends State\<RoleSelectionScreen\> {  
  bool \_loading \= false;  
  // ...  
}
Enter fullscreen mode Exit fullscreen mode

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.

import '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);  
  @override  
  State\<DoctorHomeScreen\> createState() \=\> \_DoctorHomeScreenState();  
}  
class \_DoctorHomeScreenState extends State\<DoctorHomeScreen\> {  
  late final StreamChannelListController \_channelListController;  
  @override  
  void initState() {  
    super.initState();  
    \_channelListController \= StreamChannelListController(  
      client: StreamClientProvider.client,  
      filter: Filter.in\_('members', \[StreamClientProvider.client.state.currentUser\!.id\]),  
      limit: 20,  
    );  
  }  
  @override  
  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'),  
                          ),  
                        \],  
                      ),  
                    ),  
                  );  
                },
Enter fullscreen mode Exit fullscreen mode

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:

import '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);  
  @override  
  State\<PatientHomeScreen\> createState() \=\> \_PatientHomeScreenState();  
}  
class \_PatientHomeScreenState extends State\<PatientHomeScreen\> {  
  late final StreamChannelListController \_channelListController;  
  @override  
  void initState() {  
    super.initState();  
    \_channelListController \= StreamChannelListController(  
      client: StreamClientProvider.client,  
      filter: Filter.in\_('members', \[StreamClientProvider.client.state.currentUser\!.id\]),  
      limit: 20,  
    );  
  }  
  @override  
  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'),  
                          ),
Enter fullscreen mode Exit fullscreen mode

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:

filter: Filter.in\_('members', \[StreamClientProvider.client.state.currentUser\!.id\]),
Enter fullscreen mode Exit fullscreen mode

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:

  1. We create or retrieve a messaging channel with both patient and doctor IDs.
  2. If it doesn’t exist, Stream creates it automatically.
  3. We call await channel.create() to finalize it.
  4. 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:

import '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});  
  @override  
  State\<ChatScreen\> createState() \=\> \_ChatScreenState();  
}  
class \_ChatScreenState extends State\<ChatScreen\> {  
  late Channel channel;  
  @override  
  void initState() {  
    super.initState();  
    channel \= StreamClientProvider.client.channel(  
      'messaging',  
      id: widget.channelId,  
      extraData: {'members': \[widget.userId\]},  
    );  
    channel.watch();  
  }  
  @override  
  Widget build(BuildContext context) {  
    return StreamChannel(  
      channel: channel,  
      child: Scaffold(  
        appBar: StreamChannelHeader(),  
        body: Column(  
          children: \[  
            Expanded(child: StreamMessageListView()),  
            StreamMessageInput(),  
          \],  
        ),  
      ),  
    );  
  }  
}
Enter fullscreen mode Exit fullscreen mode

When the widget loads, initState() creates a Channel from the global client in StreamClientProvider.

It calls:

client.channel(  
  'messaging',  
  id: widget.channelId,  
  extraData: {'members': \[widget.userId\]},  
)
Enter fullscreen mode Exit fullscreen mode

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.

App

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:

import '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,  
    );  
  }  
}
Enter fullscreen mode Exit fullscreen mode

Building your own app? Get early access to our Livestream or Video Calling API and launch in days!

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:

import '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;
Enter fullscreen mode Exit fullscreen mode
  • 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:

static final StreamChatClient client \= StreamChatClient(  
  dotenv.env\['STREAM\_API\_KEY'\]\!,  
  logLevel: Level.INFO,  
);
Enter fullscreen mode Exit fullscreen mode

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:

static Future\<void\> connectUser(String userId, {String? name}) async {  
  if (client.state.currentUser?.id \== userId) return;  
  await client.disconnectUser();
Enter fullscreen mode Exit fullscreen mode

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:

final tokenEndpoint = dotenv.env['TOKEN_ENDPOINT'] ?? 'http://localhost:3000/token';

final response = await http.get(Uri.parse('$tokenEndpoint?user_id=$userId'));
Enter fullscreen mode Exit fullscreen mode

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:

if (response.statusCode != 200) {
  throw Exception(
    'Failed to fetch Stream Chat token: ${response.body}',
  );
}
final token = jsonDecode(response.body)['token'];
Enter fullscreen mode Exit fullscreen mode

Finally, it connects the user to Stream:

await client.connectUser(  
  User(id: userId, name: name ?? userId),  
  token,  
);
Enter fullscreen mode Exit fullscreen mode

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.

App

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.

import '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;  
  @override  
  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\!,  
      ),  
    );  
  }  
}
Enter fullscreen mode Exit fullscreen mode

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 in the GitHub repository.

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 code from the GitHub repository.

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:

bash
flutter 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:

bash
flutter 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:

http://localhost:60767/

This is the application's landing page.

MedTalk

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:

App

Which then redirects to the chat page, with this URL (if you selected Patient role), like so:

http://localhost:57331//#/patient

Chat

At the moment, there are no active chats yet.

The users then show:

App

Then the patients:

App

And the Patient/Doctor chat loads:

App

From the Stream dashboard, check the chat logs to confirm the application is integrated with Stream through the Flutter SDK.

Flutter

Note: We built a Node.js server for handling the Stream tokens for chat/video.

App

Test the endpoint in your browser or with curl:

App

You should get a JSON response like:

JSON

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:

bash
node getstream-token-server.js

This starts the node server and shows an output like so:

Node

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.

App

It generates a token like so:

Node

You can also view the server logs in the Node.js terminal.

App

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:

flutter 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.

App

The Flutter app then loads and opens in your Chrome browser.

App

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.

App

Click on any user, say Bob Brown, to start a chat

App

You can also confirm that the messages are sent through Stream by viewing the real-time chat logs in your dashboard.

App

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

App

Click on any of the Doctors, say Doctor Emily, to start a conversation:

App

1. As a Doctor:

  • Click "Start Chat" with a patient
  • Send a message in the chat
  • The chat should now appear in your chat list

2. 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

3. 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.

App

To run the app locally, clone the GitHub repository with this command.

git 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.

flutter pub get

Also, ensure your Stream API keys are added to an .env file in the project directory.

Then run the application with:

flutter 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.

Top comments (0)