Live Chat With Pusher Using Provider

Learn how to setup a real-time messaging Flutter App using Pusher API and a Go backend deployed as a containerised web service to GCP Cloud Run. By Wilberforce Uwadiegwu.

Leave a rating/review
Download materials
Save for later
Share
You are currently viewing page 2 of 4 of this article. Click here to view the first page.

Configuring Pusher

In Android Studio or Visual Studio Code, open main.dart, in main(), update the appConfig:

  • apiUrl: the service URL from the deployment step.
  • pusherAPIKey: the Pusher API key from the Pusher step.
  • pusherCluster: the Pusher cluster from the Pusher step.

Inside the messaging package, create a messages_view_model.dart file. Then create a class inside:

import 'package:flutter/material.dart';
import 'package:pusher_channels_flutter/pusher_channels_flutter.dart';
import '../common/get_it.dart';

class MessagesViewModel extends ChangeNotifier {
  PusherChannelsFlutter? pusher;

  MessagesViewModel() {
    _setUpClient();
  }

  void _setUpClient() async {
    pusher = await getIt.getAsync<PusherChannelsFlutter>();
    await pusher!.connect();
  }

  @override
  void dispose() {
    pusher?.disconnect();
    super.dispose();
  }
}

Provider is being used for state management; hence the view model extends ChangeNotifier.

In _setUpClient(), you retrieved the Pusher client from getIt service locator and opened a connection. Because you’re a good citizen, you cleaned up after yourself and closed this connection in dispose().

In theory, everything should work fine, but you’ll test this in the next step.

Receiving Messages

You’ll need two instances of the app running on different devices. One of which is an admin account and the other a customer account. Remember the admin checkbox on the signup page earlier? Check it to create an admin account, and uncheck it to create a customer account.

Run the app and sign up. You should see this:

Home page customer and admin apps

The left one is running the user account, and the right is the admin account:

Still in MessagesViewModel, import 'message_response.dart', add more instance variables below pusher then update the constructor like so:

final String channel;
final _messages = <Message>[];
List<Message> get messages => _messages;

MessagesViewModel(this.channel) {
  ...
}

channel is a unique identifier for the line of communication between the customer and the CX specialist. And _messages is a list of sent or received messages. You’ll use these in the following steps.

In _setUpClient(), subscribe to new messages after the connection:

void _setUpClient() async {
  ...
  pusher!.subscribe(channelName: channel, onEvent: _onNewMessage);
}

_onNewMessage() will be called whenever a new message comes in. Inside it, you’ll parse the data from Pusher into a Message object and update the messages list. So import 'dart:convert' and declare _onNewMessage() below _setUpClient():

void _onNewMessage(dynamic event) {
  final data = json.decode(event.data as String) as Map<String, dynamic>;
  final message = Message.fromJson(data);
  _updateOrAddMessage(message);
}

Similarly, declare _updateOrAddMessage() below _onNewMessage():

void _updateOrAddMessage(Message message) {
  final index = _messages.indexOf(message);

  if (index >= 0) {
    _messages[index] = message;
  } else {
    _messages.add(message);
  }
  notifyListeners();
}

The instructions above update the list if the message already exists, and it appends to it otherwise.

Next, update dispose() to stop listening to new messages and clear the messages list.

void dispose() {
  pusher?.unsubscribe(channelName: channel);
  pusher?.disconnect();
  _messages.clear();
  super.dispose();
}

Sending Messages

Inside the messaging package, there’s a messages_repository.dart file which contains the MessagesRepository class. It’ll make all messaging-related API calls to your web service on Cloud Run. You’ll invoke its sendMessage() to send a new message.

Now, import 'messages_repository.dart' to MessagesViewModel. Then add two new instance variables below the previous ones and update the constructor:

final textController = TextEditingController();
final MessagesRepository repo;


MessagesViewModel(this.channel, this.repo) {
  ...
}

Add these import statements:

import 'package:uuid/uuid.dart';
import '../auth/auth_view_model.dart';

Declare an async sendMessage() below _onNewMessage(). Later, you’ll invoke this method from the widget when the user hits the send icon. Then retrieve the text and currently logged-in user like so:

void sendMessage() async {
  final text = textController.text.trim();
  if (text.isEmpty) return;
  final currentUser = getIt<AuthViewModel>().auth.user;
}

Next, create an instance of the Message class, clear the text from textController and update Provider as follows:

void sendMessage() async {
  ...
  final message = Message(
    sentAt: DateTime.now(),
    data: MessageData(
      clientId: const Uuid().v4(),
      channel: channel,
      text: text,
    ),
    from: currentUser!,
    status: MessageStatus.sending,
  );
  textController.clear();
  notifyListeners();
}

The app uses clientId to identify all the messages it sends uniquely. Two instances of message are equal if their data.clientId are the same. This is why == was overridden in both Message and MessageData.

A message has three states that are enumerated in MessageStatus and here’s what they mean:

  1. sending: there’s a pending API call to send this message.
  2. sent: the API call returned, and the message was successfully sent.
  3. failed: the API call returned, but the message failed to send.

Next, in the same method below the previous pieces of code, send the message and update the messages list.

void sendMessage() async {
  ...
  final success = await repo.sendMessage(message);
  final update = message.copy(
    status: success ? MessageStatus.sent : MessageStatus.failed,
  );
  _updateOrAddMessage(update);
}

Build and run the app, but don’t expect any changes at this point. You’ll start working on the UI next.

Implementing UI

You’ve done the heavy lifting, and now it’s time to paint some pixels!
In this section, you’ll build a text field to enter new messages and a ListView to display these messages.

Building the Messages Screen

You’ll start with the text field. Still in MessagesViewModel, add another instance variable below the others:

final focusNode = FocusScopeNode();

Adding An Input Field

You’ll use this to control the visibility of the keyboard.

Open messages_screen.dart in the messaging package, import 'messages_view_model.dart' and create a stateless widget like this:

class _InputWidget extends StatelessWidget {
  final MessagesViewModel vm;
  final double bottom;

  const _InputWidget({required this.vm, required this.bottom, Key? key})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Container();
  }
}

This empty widget accepts an instance of MessagesViewModel, which you’ll be using in a moment.

Replace the build method with this:

Widget build(BuildContext context) {
  return Transform.translate(
    offset: Offset(0.0, -1 * bottom),
    child: SafeArea(
      bottom: bottom < 10,
      child: TextField(
        minLines: 1,
        maxLines: 3,
        focusNode: vm.focusNode,
        controller: vm.textController,
        autofocus: false,
        decoration: InputDecoration(
          filled: true,
          fillColor: Theme.of(context).canvasColor,
          hintText: 'Enter a message',
          contentPadding: const EdgeInsets.symmetric(
            horizontal: 20,
            vertical: 5,
          ),
          suffixIcon: IconButton(
            onPressed: vm.sendMessage,
            icon: const Icon(Icons.send),
          ),
        ),
      ),
    ),
  );
}

The build method returns a Transform widget with a SafeArea; this ensures the text field always sticks to the bottom regardless of the visibility of the keyboard. Notice that you're passing the focusNode and textController from the view model to the text field. Additionally, the suffixIcon, a send icon, invokes the sendMessage() of the view model.

Next, add two new instance variables to MessagesViewModel like so:

  final scrollController = ScrollController();
  bool loading = true;

You'll update the scroll position of the ListView with scrollController when a new message arrives. You'll use loading to determine the state of the messages screen. Therefore, declare _scrollToBottom() above dispose() like so:

void _scrollToBottom() {
  if (_messages.isEmpty) return;
  WidgetsBinding.instance.addPostFrameCallback((_) {
    scrollController.jumpTo(scrollController.position.maxScrollExtent);
  });
}

This scrolls to the bottom of the ListView after the app has updated it.

Likewise, declare _fetchPreviousMessages() below _onNewMessage(). It'll fetch the message history when a user opens the messages screen.

void _fetchPreviousMessages(String userId) async {
  final messages = await repo.fetchMessages(userId);
  _messages.addAll(messages);
  loading = false;
  notifyListeners();
  _scrollToBottom();
}

Similarly, call _scrollToBottom() in bothsendMessage() and _updateOrAddMessage after the call to notifyListeners();:

void _updateOrAddMessage(Message message) {
  ...
  notifyListeners();
  _scrollToBottom();
}

void sendMessage() async {
  ...
  notifyListeners();
  _scrollToBottom();
  ...
}

Now, call _fetchPreviousMessages() as the last statement in _setUpClient():

void _setUpClient() async {
  ...
  _fetchPreviousMessages(channel);
}