Live Chat With Pusher Using Provider
[ad_1]
Customer satisfaction could make or break a product. One way you could increase customer satisfaction is through a proper conflict resolution channel.
As a Software Engineer, you might not interact directly with customers, but you can build a channel for them to easily reach out to customer experience (CX) specialists and vice versa. In this tutorial, you’ll build Petplus, a mobile app for a veterinary company that doubles as an animal shelter. You’ll flesh out the real-time messaging functionality of the app, which will consist of two clients; one for users and the other for CX specialists. In this process, you’ll learn how to:
- Build complex interactive UIs.
- Build end-to-end messaging functionality.
- Deploy a containerized web service to GCP Cloud Run.
Getting Started
Download the project by clicking Download Materials at the top or bottom of this tutorial. Unzip the project, and you’ll find two folders: backend and mobile. Written in Go, the backend directory contains the code that’ll power the mobile app. Apart from deploying it, you won’t be interacting with it much.
The mobile directory is where you’ll work from; open it and open the starter folder inside with the latest version of Android Studio or Visual Studio Code. Part of the mobile app, like the API integration, is already complete so you can focus on the subject matter of this tutorial.
Open pubspec.yaml and click the Pub get tab that appears in your IDE. Open lib/main.dart and run the project to see this on your target emulator or device:
If you try to sign up, you’ll get an error because you still need to deploy the back end. You’ll do that in the next section.
Note: This tutorial assumes that you’re working from a Unix-like workstation such as macOS or Ubuntu. Additionally, you should have some experience with the Terminal and Firebase.
Setting up and Deploying the Back end
In this section, you’ll set up Pusher, Firebase, and GCP. You’ll also deploy the files in the backend directory to GCP Cloud Run. Pusher provides a hosted pub/sub messaging API called Channels. This API lets the Petplus app create and listen to events on a channel and then act upon them directly. The app will implement each customer service message as an event, thus creating a real-time messaging functionality between clients. GCP describes Cloud Run as a “serverless compute platform that abstracts away all infrastructure management, so you can focus on what matters most — building great applications.”
Setting up Pusher
Pusher will power the real-time messaging back end for the apps. Go to Pusher and sign up. After signup, click “Get Started” on the setup page:
Next, complete the Channels set up by filling in the form like so:
Finally, scroll down to Step 2 on the page and note down the following values: AppID, Key, Secret and Cluster:
Setting up Firebase
You’ll use Firebase for user account management and persisting user messages.
Follow steps 1 and 2 on this page to set up Firebase Project and enable Firebase Authentication. Note that Google authentication is not required.
Next, click Firestore Database from the left pane on the Firebase Console under the Build section. Enable the database.
Lastly, click the Indexes tab and create a composite index like shown below:
When fetching the message history, the web service orders the query by the sentAt field; hence you created an index so Firestore can process the query.
Setting up GCP
Once you’ve finished with Firebase, you have to set up GCP for the same project. The web service uses two core GCP services: Cloud Run and Cloud Storage. You’ll deploy the web service to Cloud Run, and the images uploaded by users in messages will be hosted on Cloud Storage. What’ll this cost you? If you follow the steps in this tutorial exactly, you should stay within the free tier, so it’s free. Well, free to you; Google is picking up the bill!
Now, open GCP Console. Accept the terms and conditions if you still need to do so. Select the Firebase project you created earlier and enable billing for it. For new accounts, you might be eligible for a free trial; enable it.
Deploying the Go Service
Now, you’ll build and deploy the web service app. The complexities of the deployment process have been abstracted into a bespoke Makefile to enable easier facilitation. So you only have to run two make
commands to deploy. However, you have to install some software:
- Golang: the web service is written in Go; hence it’s needed to compile it.
- Docker: to containerize the Go app before deploying it with gcloud. Start Docker after the installation.
- gcloud cli: to deploy the Docker container to cloud Run.
- yq: to parse the YAML configuration in the Makefile.
Next, fill in the config file. Inside the folder you unzipped earlier, using any text editor, open the config.yaml file inside backend directory. Fill it like so:
-
port
: Leave this empty; it’ll be read from Cloud Run’s environment variables. -
gcpProject
: The Firebase or GCP project id. You can find it in the Firebase project settings. -
messageImagesBucket
: The name of the bucket where images from messages will be stored. You can choose a name yourself using these guidelines. -
pusherId
: Pusher AppId from previous step. -
pusherKey
: Pusher key from previous step. -
pusherSecret
: Pusher Secret from previous step. -
pusherCluster
: Pusher Cluster from previous step. -
firebaseAPIKey
: Firebase Web API key. You can find it in the Firebase project settings, like the Firebase project id.
Inside the backend directory is a Makefile; this is the deploy script. Using Terminal, run these commands sequentially from this directory:
-
make setup-gcp
: creates the storage bucket with the name you filled in above and enables Cloud Run for the project. -
make deploy
: builds and deploys the docker container to Cloud Run.
If both commands complete successfully, you’ll see this on the command line:
The mobile app needs the service URL, so copy it.
Good job on completing this step!
Sending and Receiving Messages
In the previous section, you deployed the Go service and got the service URL. In this section, you’ll set up Pusher on the mobile and implement the messaging functionality.
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);
}
Adding the Messages View
Like you did for _InputWidget
in messages_screen.dark
, create another stateless widget that accepts a MessagesViewModel
like this:
class _BodyWidget extends StatelessWidget {
final MessagesViewModel vm;
final double bottom;
const _BodyWidget({required this.vm, required this.bottom, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
// 1
if (vm.loading) {
return const Center(
child: CircularProgressIndicator.adaptive(),
);
}
final messages = vm.messages;
// 2
if (messages.isEmpty) {
return const Center(child: Text('You have not sent any messages yet'));
}
// 3
return ListView.builder(
itemCount: messages.length,
controller: vm.scrollController,
padding: EdgeInsets.only(bottom: bottom),
itemBuilder: (_, i) {
return Text(
messages[i].data.text ?? '',
key: ValueKey(messages[i].data.clientId),
);
});
}
}
- Display a progress indicator if the message history is loading.
- Display an error text if there are no messages to display.
- Display a ListView of the messages. In the interim, each message will be a Text.
Lastly, import 'package:provider/provider.dart'
, '../common/get_it.dart'
and '../common/common_scaffold.dart'
. Then replace the build function in MessagesScreen widget with:
Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).viewInsets.bottom;
return ChangeNotifierProvider<MessagesViewModel>(
create: (_) => MessagesViewModel(channel, getIt()),
child: Consumer<MessagesViewModel>(
builder: (ctx, vm, _) {
return CommonScaffold(
title: title,
body: GestureDetector(
onTap: vm.focusNode.unfocus,
child: _BodyWidget(vm: vm, bottom: bottom),
),
bottomNavigationBar: _InputWidget(vm: vm, bottom: bottom),
);
},
),
);
}
This will render _BodyWidget in the body of the scaffold and _InputWidget as the bottom navigation bar. Notice the method supplied to onTap
of the GestureDetector; when the user taps outside the keyboard, this will dismiss it.
Run the app for both accounts, and you should have a similar experience:
The left is the customer account, and the right is the admin account.
Building the Message Widget
You’re currently rendering each message in a Text widget; in this section, you’ll garnish the UI to make it more informative.
Start by creating a message_widget.dart inside the messaging
package. Create a stateless widget that accepts a Message
object:
import 'package:flutter/material.dart';
import 'message_response.dart';
class MessageWidget extends StatelessWidget {
final Message message;
const MessageWidget({required this.message, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container();
}
}
Import '../auth/auth_view_model.dart'
and '../common/get_it.dart'
. Design-wise, the widget should be 75% of the screen width, and messages sent by the currently logged-in user should float to the left and otherwise to the right. Therefore, replace the build function with this:
Widget build(BuildContext context) {
final isSender = message.from.id == getIt<AuthViewModel>().auth.user?.id;
return Align(
alignment: isSender ? Alignment.topRight : Alignment.topLeft,
child: ConstrainedBox(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
child: Container(),
),
);
}
Next, add borders, background color and a child
to the empty Container:
Widget build(BuildContext context) {
...
const radius = Radius.circular(10);
return Align(
...
child: ConstrainedBox(
...
child: Container(
padding: const EdgeInsets.all(10),
margin: const EdgeInsets.all(5),
decoration: BoxDecoration(
color: isSender ? Colors.black87 : Colors.grey[50],
border: Border.all(
color: isSender ? Colors.transparent : Colors.grey[300]!),
borderRadius: BorderRadius.only(
topLeft: radius,
topRight: radius,
bottomLeft: isSender ? radius : Radius.zero,
bottomRight: isSender ? Radius.zero : radius,
),
),
child: Column(),
),
),
);
}
Remember how a message
has different states? This needs to reflect on the UI. For each state, display a different widget.
-
sending
: a progress indicator. -
sent
: a double check icon if the current user sent the message. -
failed
: an error icon.
Import '../common/extensions.dart'
and create a method below build()
that switches on these states and returns the appropriate widget:
Widget _getStatus(Message message, bool isSender, BuildContext context) {
switch (message.status) {
case MessageStatus.sending:
return const SizedBox.square(
dimension: 10,
child: CircularProgressIndicator(
strokeWidth: 2,
),
);
case MessageStatus.sent:
return Row(
children: [
if (isSender)
const Icon(
Icons.done_all,
size: 10,
color: Colors.white,
),
if (isSender) const SizedBox(width: 10),
Text(
context.getFormattedTime(message.sentAt),
style: TextStyle(
color: isSender ? Colors.white : Colors.black,
fontSize: 10,
),
)
],
);
case MessageStatus.failed:
return const Icon(
Icons.error_outline,
size: 10,
color: Colors.redAccent,
);
}
}
context.getFormattedTime()
returns a time or date depending on the date of the message.
Now, add properties to the Column widget in build()
:
Widget build(BuildContext context) {
...
final msgData = message.data;
return Align(
...
child: ConstrainedBox(
...
child: Container(
...
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
msgData.text!,
style: TextStyle(
color: isSender ? Colors.white : Colors.black,
),
),
const SizedBox(height: 5),
_getStatus(message, isSender, context),
],
),
),
),
);
}
Lastly, go back to messages_screen.dart and import 'message_widget.dart'
. Then in _BodyWidget
, update the ListView in the build()
with:
Widget build(BuildContext context) {
...
return ListView.builder(
...
itemBuilder: (_, i) {
final message = messages[i];
return MessageWidget(
message: message,
key: ValueKey(message.data.clientId),
);
},
);
}
Run on both devices:
Supporting Images
In addition to texts, you’ll add the functionality to send images. The customer will pick images from their photo gallery, and you’ll upload these images to the back end. Additionally, you’ll also display images from the back end. A message can contain only text, only images or both. You’ll use image_picker to select images from the host device.
Go back to the MessageWidget and add these below the other variables in build():
final images = msgData.images ?? msgData.localImages;
final hasText = !msgData.text.isNullOrBlank();
final hasImages = images != null && images.isNotEmpty;
msgData.images
are URLs of the images already uploaded. You’ll use Image.network()
to display such images. msgData.localImages
are file handles for images that exist on the host device; you’ll display them with Image.file()
.
Next, import 'dart:io'
and 'package:image_picker/image_picker.dart'
. Afterwards, replace the Text widget in build()
with:
if (hasText)
Text(
msgData.text!,
style:
TextStyle(color: isSender ? Colors.white : Colors.black),
),
if (hasImages && hasText) const SizedBox(height: 15),
if (hasImages)
GridView.count(
crossAxisCount: images.length > 1 ? 2 : 1,
crossAxisSpacing: 5,
mainAxisSpacing: 5,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1,
children: images
.map<Widget>(
(e) => ClipRRect(
borderRadius: BorderRadius.circular(10),
child: e is XFile
? Image.file(File(e.path), fit: BoxFit.cover)
: Image.network('$e', fit: BoxFit.cover)),
)
.toList(),
),
You’re displaying the images in a non-scrolling GridView.
Similarly, open messages_view_model.dart and import 'dart:io'
and 'package:image_picker/image_picker.dart'
. Then, add these below the instance variables in MessagesViewModel;
final _picker = ImagePicker();
final _images = <XFile>[];
List<XFile> get images => _images;
Next, add two methods in the view model:
void pickImages() async {
final images = await _picker.pickMultiImage(maxWidth: 1000);
if (images == null || images.isEmpty) return;
_images.addAll(images);
notifyListeners();
}
void removeImage(int index) {
if (index < 0 || ((_images.length - 1) > index)) return;
_images.removeAt(index);
notifyListeners();
}
While you’ll call pickImages()
to add images, you’ll invoke removeImage()
to remove an image.
Since you’ll send the images alongside the text in sendMessage()
, update it like so:
void sendMessage() async {
...
if (text.isEmpty && _images.isEmpty) return;
...
final message = Message(
...
data: MessageData(
...
localImages: _images.map((e) => e).toList(),
),
...
);
_images.clear();
...
}
The last step here is to clear _images
in onDispose()
:
void dispose() {
...
_images.clear();
super.dispose();
}
Displaying Images
You have to show the user the images they chose and also allow them to remove them. So, head over to messages_screen.dart and import 'dart:io'
and 'package:image_picker/image_picker.dart'
. Afterward, create a stateless widget below _InputWidget
. This widget will render a single image.
class _ImageWidget extends StatelessWidget {
final XFile file;
final VoidCallback onRemove;
final double size;
const _ImageWidget({
Key? key,
required this.onRemove,
required this.file,
required this.size,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return Container();
}
}
Since the images it’ll display are local files from the image picker, you don’t need to handle image URLs like you did for MessageWidget. Replace the build()
of _ImageWidget with:
Widget build(BuildContext context) {
final imageSize = size - 15;
return Padding(
padding: const EdgeInsets.only(left: 5, right: 10),
child: SizedBox(
height: size,
width: size,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
top: 15,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(
File(file.path),
width: imageSize,
height: imageSize,
fit: BoxFit.cover,
),
),
),
Positioned(
top: -10,
right: -10,
child: IconButton(
onPressed: onRemove,
icon: const Icon(Icons.cancel),
),
)
],
),
),
);
}
This will display an image with round edges, with an “x” icon at the top-right.
Next, declare a variable inside build()
of _InputWidget
, above the return statement.
Widget build(BuildContext context) {
final imageSize = MediaQuery.of(context).size.width * 0.21;
...
}
Still, in _InputWidget
, wrap the TextField
in a Column
. You’ll display a horizontal list of images above the text field like so:
Widget build(BuildContext context) {
...
return Transform.translate(
...
child: SafeArea(
...
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: vm.images.isEmpty ? 0 : imageSize,
child: ListView.builder(
itemCount: vm.images.length,
scrollDirection: Axis.horizontal,
itemBuilder: (ctx, i) {
final file = vm.images[i];
return _ImageWidget(
onRemove: () => vm.removeImage(i),
file: file,
size: imageSize,
);
},
),
),
TextField(
...
),
],
),
),
);
}
Add a suffix icon that’ll trigger the image picker:
TextField(
...
prefixIcon: IconButton(
onPressed: vm.pickImages,
icon: const Icon(Icons.add),
),
)
Run the app on both devices and send an image from any of them. You will see something like this:
That’s all. Great job on completing this tutorial!
Where to Go From Here
The final directory inside the mobile directory contains the full code used in this tutorial, and you can find it in the zipped file you downloaded earlier. You can still download it by clicking Download Materials at the top or bottom of this tutorial.
In this tutorial, you deployed a Golang service on Cloud Run and learned how to use Pusher to implement real-time chat. To make improvements to the memory footprint and performance of the app, one suggestion is to paginate the chat, letting messages load in pages rather than loading all at once. You can improve the app’s functionality by adding support for resending messages that failed to send. You could also use AnimatedList instead of ListView to improve the granularity of the entrance of the message widgets. After playing around, remember to delete the project from GCP, so it doesn’t incur any charges.
We hope you enjoyed this tutorial. If you have any questions or comments, please join the forum discussion below!
[ad_2]
Source link