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.
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Sign up/Sign in
With a free Kodeco account you can download source code, track your progress, bookmark, personalise your learner profile and more!
Create accountAlready a member of Kodeco? Sign in
Contents
Live Chat With Pusher Using Provider
30 mins
- Getting Started
- Setting up and Deploying the Back end
- Setting up Pusher
- Setting up Firebase
- Setting up GCP
- Deploying the Go Service
- Sending and Receiving Messages
- Configuring Pusher
- Receiving Messages
- Sending Messages
- Implementing UI
- Building the Messages Screen
- Adding An Input Field
- Adding the Messages View
- Building the Message Widget
- Supporting Images
- Displaying Images
- Where to Go From Here
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:
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:
-
sending
: there’s a pending API call to send this message. -
sent
: the API call returned, and the message was successfully sent. -
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);
}