Skip to content

Commit e83c426

Browse files
dhyash-simformaditya-css
authored andcommitted
feat: ✨ Add Typing Indicator in ChatViewList
1 parent 43bd95b commit e83c426

19 files changed

+487
-156
lines changed

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,4 +22,6 @@ doc/api/
2222
.idea/
2323
.flutter-plugins
2424
/.flutter-plugins-dependencies
25-
chat_ui.iml
25+
chat_ui.iml
26+
27+
devtools_options.yaml

example/.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
.pub-cache/
3131
.pub/
3232
/build/
33+
devtools_options.yaml
3334

3435
# Web related
3536

example/lib/chat_view_list_screen.dart

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,9 @@ class _ChatViewListScreenState extends State<ChatViewListScreen> {
5353
),
5454
config: ChatViewListConfig(
5555
tileConfig: ListTileConfig(
56+
typingStatusConfig: const TypingStatusConfig(
57+
showUserNames: true,
58+
),
5659
unreadCountConfig: const UnreadCountConfig(
5760
style: UnreadCountStyle.ninetyNinePlus,
5861
),
@@ -81,7 +84,7 @@ class _ChatViewListScreenState extends State<ChatViewListScreen> {
8184
if (value.isEmpty) {
8285
return null;
8386
}
84-
final list = _chatListController?.initialChatList
87+
final list = _chatListController?.chatListMap.values
8588
.where((chat) => chat.name
8689
.toLowerCase()
8790
.contains(value.toLowerCase()))

lib/src/controller/chat_list_view_controller.dart

Lines changed: 57 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -24,63 +24,100 @@ import 'dart:async';
2424
import 'package:flutter/material.dart';
2525

2626
import '../models/chat_view_list_item.dart';
27+
import '../values/typedefs.dart';
2728

2829
class ChatViewListController {
2930
ChatViewListController({
30-
required this.initialChatList,
31+
required List<ChatViewListItem> initialChatList,
3132
required this.scrollController,
3233
this.disposeOtherResources = true,
3334
}) {
34-
// Add the initial chat list to the stream controller after the first frame.
35+
final chatListLength = initialChatList.length;
36+
37+
final chatsMap = {
38+
for (var i = 0; i < chatListLength; i++)
39+
if (initialChatList[i] case final chat) chat.id: chat,
40+
};
41+
42+
chatListMap = chatsMap;
43+
44+
// Adds the current chat map to the stream controller
45+
// after the first frame render.
3546
Future.delayed(
3647
Duration.zero,
37-
() => _chatListStreamController.add(initialChatList),
48+
() => _chatListStreamController.add(chatListMap),
3849
);
3950
}
4051

41-
/// Represents initial chat list.
42-
List<ChatViewListItem> initialChatList = [];
52+
/// Stores and manages chat items by their unique IDs.
53+
/// A map is used for efficient lookup, update, and removal of chats
54+
/// by their unique id.
55+
Map<String, ChatViewListItem> chatListMap = {};
4356

4457
/// Provides scroll controller for chat list.
4558
ScrollController scrollController;
4659

4760
final bool disposeOtherResources;
4861

49-
/// Represents chat list user stream
50-
final StreamController<List<ChatViewListItem>> _chatListStreamController =
51-
StreamController.broadcast();
62+
/// Stream controller to manage the chat list stream.
63+
final StreamController<Map<String, ChatViewListItem>>
64+
_chatListStreamController =
65+
StreamController<Map<String, ChatViewListItem>>.broadcast();
5266

5367
late final Stream<List<ChatViewListItem>> chatListStream =
54-
_chatListStreamController.stream;
68+
_chatListStreamController.stream.map(
69+
(chatMap) => chatMap.values.toList(),
70+
);
5571

5672
/// Adds a chat to the chat list.
5773
void addChat(ChatViewListItem chat) {
58-
initialChatList.add(chat);
74+
chatListMap[chat.id] = chat;
5975
if (_chatListStreamController.isClosed) return;
60-
_chatListStreamController.sink.add(initialChatList);
76+
_chatListStreamController.add(chatListMap);
6177
}
6278

6379
/// Function for loading data while pagination.
6480
void loadMoreChats(List<ChatViewListItem> chatList) {
65-
initialChatList.addAll(chatList);
81+
final chatListLength = chatList.length;
82+
chatListMap.addAll(
83+
{
84+
for (var i = 0; i < chatListLength; i++)
85+
if (chatList[i] case final chat) chat.id: chat,
86+
},
87+
);
6688
if (_chatListStreamController.isClosed) return;
67-
_chatListStreamController.sink.add(initialChatList);
89+
_chatListStreamController.add(chatListMap);
90+
}
91+
92+
/// Updates the chat entry in [chatListMap] for the given [chatId] using
93+
/// the provided [newChat] callback.
94+
///
95+
/// If the chat with [chatId] does not exist, the method returns without
96+
/// making changes.
97+
void updateChat(String chatId, UpdateChatCallback newChat) {
98+
final chat = chatListMap[chatId];
99+
if (chat == null) return;
100+
101+
chatListMap[chatId] = newChat(chat);
102+
if (_chatListStreamController.isClosed) return;
103+
_chatListStreamController.add(chatListMap);
68104
}
69105

70106
/// Adds the given chat search results to the stream after the current frame.
71107
void setSearchChats(List<ChatViewListItem> searchResults) {
72-
WidgetsBinding.instance.addPostFrameCallback(
73-
(_) {
74-
if (_chatListStreamController.isClosed) return;
75-
_chatListStreamController.sink.add(searchResults);
76-
},
77-
);
108+
final searchResultLength = searchResults.length;
109+
final searchResultMap = {
110+
for (var i = 0; i < searchResultLength; i++)
111+
if (searchResults[i] case final chat) chat.id: chat,
112+
};
113+
if (_chatListStreamController.isClosed) return;
114+
_chatListStreamController.add(searchResultMap);
78115
}
79116

80117
/// Function to clear the search results and show the original chat list.
81118
void clearSearch() {
82119
if (_chatListStreamController.isClosed) return;
83-
_chatListStreamController.sink.add(initialChatList);
120+
_chatListStreamController.add(chatListMap);
84121
}
85122

86123
/// Used to dispose ValueNotifiers and Streams.

lib/src/extensions/extensions.dart

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import 'package:intl/intl.dart';
2626

2727
import '../inherited_widgets/configurations_inherited_widgets.dart';
2828
import '../models/config_models/chat_bubble_configuration.dart';
29+
import '../models/config_models/chat_view_list/list_type_indicator_config.dart';
2930
import '../models/config_models/message_list_configuration.dart';
3031
import '../models/config_models/reply_suggestions_config.dart';
3132
import '../utils/constants/constants.dart';
@@ -151,6 +152,37 @@ extension ChatViewStateTitleExtension on String? {
151152
}
152153
}
153154

155+
extension type const TypingStatusConfigExtension(TypingStatusConfig config) {
156+
String toTypingStatus(List<String> users) {
157+
final prefix = config.prefix ?? '';
158+
final suffix = config.suffix ?? '';
159+
final showUserNames = config.showUserNames;
160+
final locale = PackageStrings.currentLocale;
161+
final text = '$prefix${locale.typing}$suffix';
162+
if (users.isEmpty) return text;
163+
164+
final count = users.length;
165+
166+
final firstName = users[0];
167+
168+
if (count == 1) {
169+
return '$firstName ${locale.isVerb} $text';
170+
} else if (count == 2) {
171+
final newText = showUserNames
172+
? '$firstName & ${users[1]} ${locale.areVerb}'
173+
: '$firstName & 1 ${locale.other} ${locale.isVerb}';
174+
return '$newText $text';
175+
} else if (showUserNames && count == 3) {
176+
return '${users[0]}, ${users[1]} & ${users[2]} ${locale.areVerb} $text';
177+
} else {
178+
final newText = showUserNames
179+
? '$firstName, ${users[1]} & ${count - 2}'
180+
: '$firstName & ${count - 1}';
181+
return '$newText ${locale.others} ${locale.areVerb} $text';
182+
}
183+
}
184+
}
185+
154186
/// Extension on State for accessing inherited widget.
155187
extension StatefulWidgetExtension on State {
156188
ChatViewInheritedWidget? get chatViewIW =>

lib/src/models/chat_view_list_item.dart

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
import 'package:chatview_utils/chatview_utils.dart';
2323

2424
import '../values/enumeration.dart';
25+
import '../values/typedefs.dart';
26+
import 'omit.dart';
2527

2628
/// Model class representing a user or group in the chat list.
2729
class ChatViewListItem {
@@ -30,6 +32,7 @@ class ChatViewListItem {
3032
required this.id,
3133
required this.name,
3234
this.chatType = ChatType.user,
35+
this.typingUsers = const <String>{},
3336
this.userActiveStatus = UserActiveStatus.offline,
3437
this.lastMessage,
3538
this.imageUrl,
@@ -60,4 +63,34 @@ class ChatViewListItem {
6063
///
6164
/// Defaults to [UserActiveStatus.offline].
6265
final UserActiveStatus userActiveStatus;
66+
67+
// TODO(YASH): Switch to User Object instead of string.
68+
/// Set of users currently typing in the chat.
69+
final Set<String> typingUsers;
70+
71+
ChatViewListItem copyWith({
72+
Defaulted<String> id = const Omit(),
73+
Defaulted<String> name = const Omit(),
74+
Defaulted<ChatType> chatType = const Omit(),
75+
Defaulted<Set<String>> typingUsers = const Omit(),
76+
Defaulted<UserActiveStatus> userActiveStatus = const Omit(),
77+
Defaulted<Message>? lastMessage = const Omit(),
78+
Defaulted<String>? imageUrl = const Omit(),
79+
Defaulted<int>? unreadCount = const Omit(),
80+
}) {
81+
return ChatViewListItem(
82+
id: id is Omit ? this.id : id as String,
83+
name: name is Omit ? this.name : name as String,
84+
chatType: chatType is Omit ? this.chatType : chatType as ChatType,
85+
typingUsers:
86+
typingUsers is Omit ? this.typingUsers : typingUsers as Set<String>,
87+
userActiveStatus: userActiveStatus is Omit
88+
? this.userActiveStatus
89+
: userActiveStatus as UserActiveStatus,
90+
lastMessage:
91+
lastMessage is Omit ? this.lastMessage : lastMessage as Message?,
92+
imageUrl: imageUrl is Omit ? this.imageUrl : imageUrl as String?,
93+
unreadCount: unreadCount is Omit ? this.unreadCount : unreadCount as int?,
94+
);
95+
}
6396
}

lib/src/models/config_models/chat_view_list/chat_view_list_config.dart

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import 'search_config.dart';
2929
class ChatViewListConfig {
3030
/// Creates a configuration object for the chat list UI.
3131
const ChatViewListConfig({
32+
this.backgroundColor = Colors.white,
3233
this.enablePagination = false,
3334
this.loadMoreConfig = const LoadMoreConfig(),
3435
this.tileConfig = const ListTileConfig(),
@@ -60,4 +61,7 @@ class ChatViewListConfig {
6061
///
6162
/// Defaults to `false`.
6263
final bool enablePagination;
64+
65+
/// Background color for the chat list.
66+
final Color backgroundColor;
6367
}

lib/src/models/config_models/chat_view_list/list_tile_config.dart

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import 'package:flutter/material.dart';
2424
import '../../../values/typedefs.dart';
2525
import '../../chat_view_list_item.dart';
2626
import 'last_message_time_config.dart';
27+
import 'list_type_indicator_config.dart';
2728
import 'unread_count_config.dart';
2829
import 'user_active_status_config.dart';
2930
import 'user_avatar_config.dart';
@@ -37,6 +38,7 @@ class ListTileConfig {
3738
this.userAvatarConfig = const UserAvatarConfig(),
3839
this.unreadCountConfig = const UnreadCountConfig(),
3940
this.userActiveStatusConfig = const UserActiveStatusConfig(),
41+
this.typingStatusConfig = const TypingStatusConfig(),
4042
this.userNameMaxLines = 1,
4143
this.userNameTextOverflow = TextOverflow.ellipsis,
4244
this.lastMessageMaxLines = 1,
@@ -47,7 +49,7 @@ class ListTileConfig {
4749
this.lastMessageTextStyle,
4850
this.onTap,
4951
this.onLongPress,
50-
this.customLastMessageListViewBuilder,
52+
this.lastMessageTileBuilder,
5153
});
5254

5355
/// Padding around the widget in the chat list.
@@ -113,5 +115,8 @@ class ListTileConfig {
113115
final bool showOnlineStatus;
114116

115117
/// Custom builder for the last message view in the chat list.
116-
final CustomLastMessageListViewBuilder? customLastMessageListViewBuilder;
118+
final ChatViewListLastMessageTileBuilder? lastMessageTileBuilder;
119+
120+
/// Configuration for the typing status indicator in the chat list.
121+
final TypingStatusConfig typingStatusConfig;
117122
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import 'package:flutter/material.dart';
2+
3+
import '../../../values/typedefs.dart';
4+
5+
/// Configuration for the chat list type indicator.
6+
/// Controls appearance and behavior of the indicator shown in chat list tiles.
7+
class TypingStatusConfig {
8+
/// Creates a [TypingStatusConfig].
9+
///
10+
/// Configuration options for the chat view list type indicator:
11+
///
12+
/// - [suffix]: Appends a string to the indicator text.
13+
/// Default is `null`.
14+
/// - [maxLines]: Limits the number of lines for the indicator text.
15+
/// Default is `1`.
16+
/// - [showUserNames]: Toggles the display of user names in the indicator.
17+
/// Default is `false`.
18+
/// - [textStyle]: Customizes the style of the indicator text.
19+
/// - [overflow]: Sets the text overflow behavior.
20+
/// Default is `TextOverflow.ellipsis`.
21+
/// - [prefix]: Prepends a string to the indicator text.
22+
/// Default is `null`.
23+
/// - [textBuilder]: Allows custom building of the indicator text.
24+
/// Default is `null`.
25+
/// - [widgetBuilder]: Allows custom building of the indicator widget.
26+
/// Default is `null`.
27+
const TypingStatusConfig({
28+
this.suffix = '...',
29+
this.maxLines = 1,
30+
this.showUserNames = false,
31+
this.textStyle = const TextStyle(
32+
fontSize: 14,
33+
fontWeight: FontWeight.w400,
34+
fontStyle: FontStyle.italic,
35+
),
36+
this.overflow = TextOverflow.ellipsis,
37+
this.prefix,
38+
this.textBuilder,
39+
this.widgetBuilder,
40+
});
41+
42+
/// Optional text appended after the typing indicator.
43+
///
44+
/// The main indicator text comes from locale `typing`.
45+
///
46+
/// Example:
47+
/// - If `suffix = '...'`,
48+
/// ➜ Display: `'typing...'`
49+
/// - If `suffix = null`,
50+
/// ➜ Display: `'typing'`
51+
final String? suffix;
52+
53+
/// Whether to show user names in the indicator.
54+
///
55+
/// If `true`, the indicator will include user names.
56+
/// Example:
57+
/// - If `showUserNames = true` and there are two users typing,
58+
/// ➜ Display: `'ChatView & Simform are typing...'`
59+
/// - If `showUserNames = false`,
60+
/// ➜ Display: `'ChatView & 1 other is typing...'`
61+
final bool showUserNames;
62+
63+
/// Optional text prepended before the typing indicator.
64+
///
65+
/// The main indicator text comes from locale `typing`.
66+
///
67+
/// Example:
68+
/// - If `prefix = 'User'`,
69+
/// ➜ Display: `'User typing...'`
70+
/// - If `prefix = null`,
71+
/// ➜ Display: `'typing...'`
72+
final String? prefix;
73+
74+
/// Style for the indicator text.
75+
final TextStyle? textStyle;
76+
77+
/// Maximum number of lines for the indicator text.
78+
final int? maxLines;
79+
80+
/// Overflow behavior for the indicator text.
81+
final TextOverflow? overflow;
82+
83+
/// Custom builder for indicator text.
84+
final ChatViewListTextBuilder? textBuilder;
85+
86+
/// Custom builder for indicator widget.
87+
final ChatViewListWidgetBuilder? widgetBuilder;
88+
}

0 commit comments

Comments
 (0)