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 3 of 4 of this article. Click here to view the first page.

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),
          );
        });
  }
}
  1. Display a progress indicator if the message history is loading.
  2. Display an error text if there are no messages to display.
  3. 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:

Screen recording realtime message

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:

Screenshot after redesigning the message widget

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();
}