Create an Interactive Chat List Screen with Typing & Read Ticks! Tutorial [24]
Key Topics covered:
- Listening for user typing events
- Showing New Message Indicator
- Design Chat List Screen
- Design Chat Message Screen Bubbles
Description:
In this tutorial, we will guide you through the step-by-step process of creating a dynamic Chat List Screen with user typing status, delivery and message read ticks, and time indicators.
We want to create ChatHeadRepositoryProvider. It is responsible for updating chat head message seen, delivered. Let's Get Started
Code Snippet(ChatHeadRepositoryProvider.dart):
- lib/core/providers/ChatHeadRepositoryProvider.dart
import 'package:appwrite/appwrite.dart';
import 'package:appwrite/models.dart';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:chat_with_bisky/core/providers/DatabaseProvider.dart';
import 'package:chat_with_bisky/model/ChatAppwrite.dart';
import 'package:chat_with_bisky/model/MessageAppwrite.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final chatHeadRepositoryProvider = Provider((ref) => ChatHeadRepositoryProvider(ref),);
class ChatHeadRepositoryProvider{
final Ref _ref;
Databases get _databases => _ref.read(databaseProvider);
ChatHeadRepositoryProvider(this._ref);
Future<void> updateChatHeadMessageSeen(String id) async{
try{
_databases.updateDocument(databaseId: Strings.databaseId,
collectionId: Strings.collectionChatsId,
documentId: id,
data: {
'read':true,
'delivered':true
});
} catch(e){
print('updateChatHeadMessageSeen Error $e');
}
}
Future<void> updateChatHeadMessageDelivered(String id) async{
try{
_databases.updateDocument(databaseId: Strings.databaseId,
collectionId: Strings.collectionChatsId,
documentId: id,
data: {
'delivered':true
});
} catch(e){
print('updateChatHeadMessageDelivered Error $e');
}
}
Future<ChatAppwrite?> getChat(String id) async{
try{
Document document = await _databases.getDocument(databaseId: Strings.databaseId,
collectionId: Strings.collectionChatsId,
documentId: id);
return ChatAppwrite.fromJson(document.data);
} catch(e){
print('getChat Error $e');
}
return null;
}
Future<void> updateChatMessageDelivered(MessageAppwrite message) async {
String chatId = '${message.senderUserId??""}${message.receiverUserId??""}';
String chatId2 = '${message.receiverUserId??""}${message.senderUserId??""}';
ChatAppwrite? chatHead = await getChat(chatId);
ChatAppwrite? chatHead2 = await getChat(chatId2);
if(chatHead != null || chatHead2 != null){
await updateChatHeadMessageDelivered(chatId);
await updateChatHeadMessageDelivered(chatId2);
}
}
Future<void> updateChatMessageRead(MessageAppwrite message) async {
String chatId = '${message.senderUserId??""}${message.receiverUserId??""}';
String chatId2 = '${message.receiverUserId??""}${message.senderUserId??""}';
ChatAppwrite? chatHead = await getChat(chatId);
ChatAppwrite? chatHead2 = await getChat(chatId2);
if(chatHead!= null|| chatHead2!= null){
updateChatHeadMessageSeen(chatId);
updateChatHeadMessageSeen(chatId2);
}
}
}
We want to cheat a ChatHeadViewModel. The view model will be responsible for handling chat head business logic like listening to typing status. Let's create a ChatHeadState and then a ChatHeadViewModel. Follow these steps whenever you are working with riverpod.
Code Snippet(ChatHeadState.dart):
- lib/model/ChatHeadState.dart
import 'package:chat_with_bisky/model/MessageAppwrite.dart';
import 'package:chat_with_bisky/model/db/ChatRealm.dart';
import 'package:chat_with_bisky/model/db/MessageRealm.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ChatHeadState.freezed.dart';
@Freezed(makeCollectionsUnmodifiable:false)
class ChatHeadState with _$ChatHeadState {
factory ChatHeadState({
@Default('') String myUserId,
@Default(null) ChatRealm? chat,
@Default(false) bool myMessage,
@Default(false) bool isTyping,
@Default(null) String? friendUserId,
}) = _ChatHeadState;
}
Code Snippet(ChatHeadViewModel.dart):
- lib/widget/ChatHeadViewModel.dart
import 'package:chat_with_bisky/model/ChatHeadState.dart';
import 'package:chat_with_bisky/model/db/ChatRealm.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:firebase_database/firebase_database.dart' as fd;
part 'ChatHeadViewModel.g.dart';
@riverpod
class ChatHeadViewModel extends _$ChatHeadViewModel{
fd.FirebaseDatabase database = fd.FirebaseDatabase.instance;
@override
ChatHeadState build(){
// ref.keepAlive();
return ChatHeadState();
}
changedChatHead(ChatRealm chat){
state = state.copyWith(chat: chat);
}
changedUserId(userId){
state = state.copyWith(myUserId: userId);
}
changedTypingStatus(isTyping){
state = state.copyWith(isTyping: isTyping);
}
changedFriendUserId(friendUserId){
state = state.copyWith(friendUserId: friendUserId);
}
Future<void> listenFriendIsTyping()async {
await database.goOnline();
final typingRef = database.ref().child('typing').child(state.chat?.senderUserId??"").child(state.myUserId);
typingRef.onValue.listen((event) {
if(event.snapshot.exists){
Map<Object?,Object?> map = event.snapshot.value as Map<Object?,Object?>;
Map<Object?,Object?> typedValue = map[map.keys.first] as Map<Object?,Object?>;
if(typedValue.containsKey('from') && typedValue['from'] != null ){
print('from $typedValue');
String from = typedValue['from'] as String;
changedFriendUserId(from);
changedTypingStatus(true);
}
}else{
changedTypingStatus(false);
changedFriendUserId(null);
}
});
}
}
Don't forget to run the following command in your terminal to generate part files flutter packages pub run build_runner build
We need to create the ChatHeadItemWidget to display chat message.
Code Snippet(ChatHeadItemWidget.dart):
- lib/widget/ChatHeadItemWidget.dart
import 'package:auto_route/auto_route.dart';
import 'package:chat_with_bisky/core/extensions/extensions.dart';
import 'package:chat_with_bisky/core/providers/ChatHeadRepositoryProvider.dart';
import 'package:chat_with_bisky/model/ChatHeadState.dart';
import 'package:chat_with_bisky/model/MessageAppwrite.dart';
import 'package:chat_with_bisky/model/UserAppwrite.dart';
import 'package:chat_with_bisky/model/db/ChatRealm.dart';
import 'package:chat_with_bisky/route/app_route/AppRouter.gr.dart';
import 'package:chat_with_bisky/service/LocalStorageService.dart';
import 'package:chat_with_bisky/widget/ChatHeadViewModel.dart';
import 'package:chat_with_bisky/widget/friend_image.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class ChatHeadItemWidget extends ConsumerStatefulWidget {
final ChatRealm chat;
final String userId;
const ChatHeadItemWidget({
super.key,
required this.chat,
required this.userId,
});
@override
_ChatHeadItemWidget createState() {
return _ChatHeadItemWidget();
}
}
class _ChatHeadItemWidget extends ConsumerState <ChatHeadItemWidget>{
ChatHeadViewModel? notifier;
ChatHeadState? model;
@override
void initState() {
super.initState();
WidgetsBinding.instance
.addPostFrameCallback((_) => initialization(context));
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final style = theme.textTheme;
notifier = ref.read(chatHeadViewModelProvider.notifier);
model = ref.watch(chatHeadViewModelProvider);
bool isRead = widget.chat.read == true;
return ListTile(
leading: FriendImage(widget.chat.base64Image),
title: Row(
// mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(widget.chat.displayName ?? "",
maxLines: 1,
style: style.labelSmall?.copyWith(fontSize: 13),
overflow: TextOverflow.ellipsis,),
),
if (model?.isTyping == true && widget.chat.senderUserId == model?.friendUserId)
Text(
' typing...',
style: style.labelSmall!.copyWith(
color: Colors.blue.shade600,
fontSize: 12
),
),
const Spacer(flex: 2,),
Text(widget.chat.sendDate!.getFormattedTime(), maxLines: 1,
overflow: TextOverflow.ellipsis,style: style.labelSmall?.copyWith(fontSize: 10),),
],
),
// subtitle: Text(friend.message ?? ""),
subtitle: Row(
children: [
Expanded(
child: chatMessage(isRead),
),
if(myMessage())
Icon(widget.chat.read == true || widget.chat.delivered == true?
Icons.done_all_rounded : Icons.done_rounded,
size: 16,
color: widget.chat.read == true? Colors.green.shade800:Colors.grey,),
if (!isRead && !myMessage())
const CircleAvatar(
backgroundColor: Colors.orange,
radius: 8,
child: Text(
'',
style: TextStyle(
fontSize: 10,
),
),
),
],
),
onTap: () async {
String userId =
await LocalStorageService.getString(
LocalStorageService.userId) ??
"";
AutoRouter.of(context).push(MessageRoute(displayName: widget.chat.displayName ?? "",
myUserId:userId,
friendUserId:widget.chat.senderUserId ?? "",friendUser: UserAppwrite(userId: widget.chat.senderUserId,
name: widget.chat.displayName),profilePicture: widget.chat.base64Image));
ref.read(chatHeadRepositoryProvider).updateChatMessageRead(MessageAppwrite(receiverUserId: widget.chat.receiverUserId,senderUserId: widget.chat.senderUserId));
},
);
}
Widget chatMessage(bool isRead) {
if (widget.chat.type == 'IMAGE') {
return const Align(
alignment: Alignment.topLeft,
child: Row(
children: [
Icon(Icons.image,color: Colors.blue),
Padding(
padding: EdgeInsets.only(left: 8.0),
child: Text("image",style: TextStyle(color: Colors.blue,fontSize: 12),),
)
],
),
);
} else if (widget.chat.type == 'VIDEO') {
return const Align(
alignment: Alignment.topLeft,
child: Row(
children: [
Icon(Icons.video_chat,color: Colors.blue,),
Padding(
padding: EdgeInsets.only(left: 8.0),
child: Text("video",style: TextStyle(color: Colors.blue,fontSize: 12),),
)
],
),
);
} else {
return Text(
widget.chat.message ?? "",
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: !isRead && !myMessage() ? FontWeight.bold : null,
),
);
}
}
bool myMessage(){
return widget.chat.userId == widget.userId;
}
initialization(BuildContext context) {
notifier?.changedChatHead(widget.chat);
notifier?.changedUserId(widget.userId);
notifier?.listenFriendIsTyping();
}
}
We need to use the new widget we created above ChatHeadItemWidget by modifying the class ChatListScreen
Code Snippet(ChatListScreen.dart):
- lib/pages/dashboard/chat/list/ChatListScreen.dart
Declare String? userId and replace ListTile with below code
import 'package:chat_with_bisky/widget/ChatHeadItemWidget.dart';
String? userId;
// replace ListTile
return ChatHeadItemWidget(chat: friend,userId: userId?? "");
Modify getChats() to assign userId of the logged in user
userId = await LocalStorageService.getString(LocalStorageService.userId) ?? "";
Add method to get Message by id in MessageRepositoryProvider
Code Snippet(MessageRepositoryProvider.dart):
- lib/core/providers/MessageRepositoryProvider.dart
import 'package:chat_with_bisky/model/MessageAppwrite.dart';
import 'package:appwrite/models.dart';
Future<MessageAppwrite?> getMessage(String id) async{
try{
Document document = await _databases.getDocument(databaseId: Strings.databaseId,
collectionId: Strings.collectionMessagesId,
documentId: id);
MessageAppwrite message = MessageAppwrite.fromJson(document.data);
message.sendDate = DateTime.parse(document.$createdAt);
return message;
} catch(e){
print('getMessage Error $e');
}
return null;
}
We need to add some parameters we missed on ChatRealm instantiation in method mapToChatRealm
Code Snippet(RealmProvider.dart):
- lib/core/providers/RealmProvider.dart
import 'package:chat_with_bisky/core/providers/MessageRepositoryProvider.dart';
import 'package:chat_with_bisky/core/providers/UserRepositoryProvider.dart';
import 'package:chat_with_bisky/core/util/Util.dart';
import 'package:chat_with_bisky/constant/strings.dart';
import 'package:flutter/services.dart';
// make the method async
Future<ChatRealm> mapToChatRealm(ChatAppwrite chat,Document document) async {
// add the code below end of is statement
bool? delivered = chat.delivered;
bool? read = chat.read;
MessageAppwrite? messageUpstream = await _ref.read(messageRepositoryProvider).getMessage(chat.messageIdUpstream?? '');
DateTime messageDate = DateTime.parse(document.$createdAt);
if(messageUpstream != null){
delivered= messageUpstream.delivered;
read= messageUpstream.read;
messageDate= messageUpstream.sendDate ?? DateTime.now();
}else{
final resultsMessages =
_realm.query<MessageRealm>(r'messageIdUpstream = $0', [chat.messageIdUpstream?? '']);
if(!resultsMessages.isEmpty){
MessageRealm retrieved = resultsMessages.first;
delivered= retrieved.delivered;
read= retrieved.read;
messageDate= retrieved.sendDate ?? DateTime.now();
}
}
final user = await _ref.read(userRepositoryProvider).getUser(chat.senderUserId??"");
String? base64;
if(user != null && user.profilePictureStorageId != null){
Storage storage = Storage(clientService.getClient());
Uint8List imageBytes = await storage.getFilePreview(
bucketId: Strings.profilePicturesBucketId,
fileId: user.profilePictureStorageId ?? "",
);
base64 = uint8ListToBase64(imageBytes);
}
return ChatRealm(id, senderUserId: chat.senderUserId,
receiverUserId: chat.receiverUserId,
message: chat.message,
type: chat.type,
sendDate: messageDate,
read: read,
displayName: chat.displayName,
count: chat.count,
userId: chat.userId,
delivered: delivered,
messageIdUpstream: chat.messageIdUpstream,
base64Image: base64
);
We made the method async. Add await where it is being called
Let's modify this page for performance sakeMessageScreen. We want to remove the delay and call initialization() method after build
Code Snippet(MessageScreen.dart):
- lib/pages/dashboard/chat/MessageScreen.dart
import 'package:chat_with_bisky/widget/ChatHeadViewModel.dart';
// add this variable in MessageScreen and _MessageScreenState
String? profilePicture;
// FriendImage() must accept profilePicture
FriendImage(profilePicture),
// Change color of onlineStatus when someone is typing to blue
messageState?.onlineStatus.contains('typing') == true?Colors.blue.shade600:Colors.grey.shade600, fontSize: 13)
initialization(BuildContext context) {
messageNotifier?.initializeMessages(myUserId, friendUserId);
messageNotifier?.getUserPresenceStatus(friendUserId);
messageNotifier?.listenFriendIsTyping();
getMessages();
}
// add below code in initState
WidgetsBinding.instance
.addPostFrameCallback((_) => initialization(context));
Add the following dependency chat_bubbles in your pubspec.yaml. We want to customize our chat screen
dependencies:
.........
chat_bubbles: ^1.4.1
Let's design chatInputWidget() to
import 'package:chat_bubbles/chat_bubbles.dart';
chatInputWidget() {
return MessageBar(
onSend: (message) => sendMessage("TEXT", message),
onTextChanged: (value) {
messageNotifier?.typingChanges(value);
},
actions: [
InkWell(
child: const Icon(
Icons.add,
color: Colors.black,
size: 24,
),
onTap: () {
_modalBottomSheet();
},
),
Padding(
padding: EdgeInsets.only(left: 8, right: 8),
child: InkWell(
child: Icon(
Icons.camera_alt,
color: Colors.blue,
size: 24,
),
onTap: () {
pickImage(ImageSource.camera);
},
),
),
],
);
}
@override
void dispose() {
ref.invalidate(chatHeadViewModelProvider); // this in dispose
}
Lets made some changes in MessageViewModel. In typingChanges method, we need to change the set from true to {'from':state.myUserId,'to':state.friendUserId}
Code Snippet(MessageViewModel.dart):
- lib/pages/dashboard/chat/MessageViewModel.dart
import 'package:chat_with_bisky/core/extensions/extensions.dart';
await database.goOnline(); // add this below fd.DatabaseReference con;
// change set of typingChanges()
con.set({'from':state.myUserId,'to':state.friendUserId});
// in getUserLastSeen method we need to call goOnline method below initializing final lastOnlineRef = ....
await database.goOnline();
// replace String dateTime = timeago.format(date); with code below
String dateTime = date.getFormattedLastSeenTime();
// add this line of code on listenFriendIsTyping()
await database.goOnline(); // first line in that method listenFriendIsTyping
We need to save friend profile picture FriendListViewModel
Code Snippet(FriendListViewModel.dart):
import 'package:flutter/services.dart';
import 'package:chat_with_bisky/core/providers/StorageProvider.dart';
import 'package:chat_with_bisky/core/providers/UserRepositoryProvider.dart';
import 'package:chat_with_bisky/core/util/Util.dart';
Storage get _storage => ref.read(storageProvider);
// look for todo persist profile picture and add below code
final user = await ref.read(userRepositoryProvider).getUser(friend.mobileNumber??"");
if(user != null && user.profilePictureStorageId != null){
Uint8List imageBytes = await _storage.getFilePreview(
bucketId: Strings.profilePicturesBucketId,
fileId: user.profilePictureStorageId ?? "",
);
friendContactRealm.base64Image = uint8ListToBase64(imageBytes);
}
We need to set friend profile picture FriendsListScreen
Code Snippet(FriendsListScreen.dart):
// change this line FriendImage(friend.mobileNumber!) to
FriendImage(friend.base64Image),
We need to load base64 image in FriendImage FriendImage
Code Snippet(FriendImage.dart):
import 'package:chat_with_bisky/core/util/Util.dart';
// the final will be this one
class FriendImage extends StatelessWidget {
final String? base64;
final AppWriteClientService _clientService =
KiwiContainer().resolve<AppWriteClientService>();
FriendImage(this.base64, {super.key});
@override
Widget build(BuildContext context) {
return getProfilePicture(base64);
}
getProfilePicture(String? base64) {
return base64 != null ?CircleAvatar(
backgroundImage: MemoryImage(base64ToUint8List(base64))):
const CircleAvatar(
backgroundImage: NetworkImage(Strings.avatarImageUrl),
);
}
}
We need to modify ChatMessageItemWe need to update chat head message read inside useEffect
Code Snippet(ChatMessageItem.dart):
- lib/widget/ChatMessageItem.dart
import 'package:chat_with_bisky/model/MessageAppwrite.dart';
// Wrap this Column inside a Row with Expanded widget in buildChatLayout() method
// remove below code
minWidth: context.media.size.width / 2,
maxWidth: context.media.size.width * 2 / 3,
// when type is video or image lets replace the code (Stack)
if (type == AttachmentType.video)
AsyncWidget(
value: ref.watch(thumbnailProvider(file.path)),
data: (data) => data != null
? Stack(
fit: StackFit.passthrough,
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BubbleNormalImage(
onTap: (){
},
id: message.sendDate.toString(),
image: Image.file(
File(data),
fit: BoxFit.cover,
),
color: myMessage?const Color(0xFF1B97F3):const Color(0xFFE8E8EE),
// tail: true,
isSender: myMessage,
) ,
),
const Icon(
Icons.play_arrow_rounded,
size: 40,
color: Colors.blue,
),
],
)
: const SizedBox(),
),
if (type == AttachmentType.image)
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: BubbleNormalImage(
onTap: () => context.openImage(file.path),
id: message.sendDate.toString(),
image: Image.file(
file,
fit: BoxFit.cover,
),
color: myMessage?const Color(0xFF1B97F3):const Color(0xFFE8E8EE),
isSender: myMessage,
) ,
)
// for type is text
if (messageAppwrite.type == AttachmentType.text) {
return BubbleNormal(
text: messageAppwrite.message ?? "",
isSender: myMessage,
color: myMessage?const Color(0xFF1B97F3):const Color(0xFFE8E8EE),
tail: true,
textStyle: TextStyle(
fontSize: 12,
color: myMessage?Colors.white:Colors.blue,
),
);
}
// remove the color of BoxDecoration
// replace this line message.sendDate!.formatDateTime, >> message.sendDate!.getFormattedTime(),
// change time color and delivered color to grey
Let's write our algorithm to format time.
Code Snippet(extensions.dart):
- lib/core/extensions/extensions.dart
import 'package:timeago/timeago.dart' as timeago;
String get formatDateTime => timeago.format(toLocal());
String getFormattedTime(){
DateFormat formatter = DateFormat('HH:mm');
String formattedText = getFormattedText();
if(formattedText == 'Today'){
return formatter.format(toLocal());
}else if(formattedText == 'Yesterday'){
return 'Yesterday at ${formatter.format(toLocal())}';
}else{
return '$formattedText at ${formatter.format(toLocal())}';
}
}
String getFormattedLastSeenTime(){
DateFormat formatter = DateFormat('HH:mm');
String formattedText = getFormattedText();
if(formattedText == 'Today'){
return 'last online at ${formatter.format(toLocal())}';
}else if(formattedText == 'Yesterday'){
return 'last online yesterday at ${formatter.format(toLocal())}';
}else{
return 'last online on $formattedText at ${formatter.format(toLocal())}';
}
}
String getFormattedText() {
DateFormat formatter = DateFormat('yyyy-MM-dd');
final now = DateTime.now();
if (formatter.format(now) == formatter.format(toLocal())) {
return 'Today';
} else if (formatter
.format(DateTime(now.year, now.month, now.day - 1)) ==
formatter.format(toLocal())) {
return 'Yesterday';
} else {
return '${DateFormat('d').format(toLocal())} ${DateFormat('MMMM').format(toLocal())} ${DateFormat('y').format(toLocal())}';
}
}
Conclusion:
Congratulations on successfully implementing the Chat List Screen with user typing status, delivery and message read ticks, and time indicators! You've come a long way from the start of this tutorial, and now you have a fully functional and impressive chat interface at your fingertips.
Throughout this journey, we covered essential concepts and techniques, allowing you to create an engaging user experience. By incorporating real-time typing status, delivery and read ticks, and time indicators, you've added a layer of dynamism and interactivity to your chat application.
Remember, continuous learning is the key to becoming a proficient developer. Keep exploring and experimenting with different features and functionalities to expand your skills even further. Stay up-to-date with the latest trends and updates in the world of app development, and don't hesitate to dive into new technologies.
If you encountered any challenges during this tutorial, remember that problem-solving is an integral part of the development process. Embrace challenges as opportunities to learn and grow as a developer.
Don't forget to maintain clean and organized code for easier maintenance and future enhancements. Well-structured code is not only beneficial for you but also for other developers who might collaborate with you on the project.
Lastly, share your creations with the world! Showcase your newly developed chat application to your friends, colleagues, or even potential employers. Who knows, your project might inspire others and lead to exciting opportunities.
Thank you for joining us on this journey of learning and discovery. We hope this tutorial has been helpful in expanding your knowledge and sparking your creativity. Stay tuned for more exciting tutorials on our channel, and don't forget to like, subscribe, and hit the notification bell to stay updated with our latest content.