diff --git a/.gitignore b/.gitignore index 33be37ee..dc41c62c 100644 --- a/.gitignore +++ b/.gitignore @@ -22,4 +22,6 @@ doc/api/ .idea/ .flutter-plugins /.flutter-plugins-dependencies -chat_ui.iml \ No newline at end of file +chat_ui.iml + +devtools_options.yaml diff --git a/example/.gitignore b/example/.gitignore index e57dbe60..2524f273 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -30,6 +30,7 @@ .pub-cache/ .pub/ /build/ +devtools_options.yaml # Web related diff --git a/example/lib/chat_view_list_screen.dart b/example/lib/chat_view_list_screen.dart index 87f9eb13..79509c96 100644 --- a/example/lib/chat_view_list_screen.dart +++ b/example/lib/chat_view_list_screen.dart @@ -53,6 +53,9 @@ class _ChatViewListScreenState extends State { ), config: ChatViewListConfig( tileConfig: ListTileConfig( + typingStatusConfig: const TypingStatusConfig( + showUserNames: true, + ), unreadCountConfig: const UnreadCountConfig( style: UnreadCountStyle.ninetyNinePlus, ), @@ -81,7 +84,7 @@ class _ChatViewListScreenState extends State { if (value.isEmpty) { return null; } - final list = _chatListController?.initialChatList + final list = _chatListController?.chatListMap.values .where((chat) => chat.name .toLowerCase() .contains(value.toLowerCase())) diff --git a/lib/src/controller/chat_list_view_controller.dart b/lib/src/controller/chat_list_view_controller.dart index 15b0c8ea..cd53dfcb 100644 --- a/lib/src/controller/chat_list_view_controller.dart +++ b/lib/src/controller/chat_list_view_controller.dart @@ -24,63 +24,100 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../models/chat_view_list_item.dart'; +import '../values/typedefs.dart'; class ChatViewListController { ChatViewListController({ - required this.initialChatList, + required List initialChatList, required this.scrollController, this.disposeOtherResources = true, }) { - // Add the initial chat list to the stream controller after the first frame. + final chatListLength = initialChatList.length; + + final chatsMap = { + for (var i = 0; i < chatListLength; i++) + if (initialChatList[i] case final chat) chat.id: chat, + }; + + chatListMap = chatsMap; + + // Adds the current chat map to the stream controller + // after the first frame render. Future.delayed( Duration.zero, - () => _chatListStreamController.add(initialChatList), + () => _chatListStreamController.add(chatListMap), ); } - /// Represents initial chat list. - List initialChatList = []; + /// Stores and manages chat items by their unique IDs. + /// A map is used for efficient lookup, update, and removal of chats + /// by their unique id. + Map chatListMap = {}; /// Provides scroll controller for chat list. ScrollController scrollController; final bool disposeOtherResources; - /// Represents chat list user stream - final StreamController> _chatListStreamController = - StreamController.broadcast(); + /// Stream controller to manage the chat list stream. + final StreamController> + _chatListStreamController = + StreamController>.broadcast(); late final Stream> chatListStream = - _chatListStreamController.stream; + _chatListStreamController.stream.map( + (chatMap) => chatMap.values.toList(), + ); /// Adds a chat to the chat list. void addChat(ChatViewListItem chat) { - initialChatList.add(chat); + chatListMap[chat.id] = chat; if (_chatListStreamController.isClosed) return; - _chatListStreamController.sink.add(initialChatList); + _chatListStreamController.add(chatListMap); } /// Function for loading data while pagination. void loadMoreChats(List chatList) { - initialChatList.addAll(chatList); + final chatListLength = chatList.length; + chatListMap.addAll( + { + for (var i = 0; i < chatListLength; i++) + if (chatList[i] case final chat) chat.id: chat, + }, + ); if (_chatListStreamController.isClosed) return; - _chatListStreamController.sink.add(initialChatList); + _chatListStreamController.add(chatListMap); + } + + /// Updates the chat entry in [chatListMap] for the given [chatId] using + /// the provided [newChat] callback. + /// + /// If the chat with [chatId] does not exist, the method returns without + /// making changes. + void updateChat(String chatId, UpdateChatCallback newChat) { + final chat = chatListMap[chatId]; + if (chat == null) return; + + chatListMap[chatId] = newChat(chat); + if (_chatListStreamController.isClosed) return; + _chatListStreamController.add(chatListMap); } /// Adds the given chat search results to the stream after the current frame. void setSearchChats(List searchResults) { - WidgetsBinding.instance.addPostFrameCallback( - (_) { - if (_chatListStreamController.isClosed) return; - _chatListStreamController.sink.add(searchResults); - }, - ); + final searchResultLength = searchResults.length; + final searchResultMap = { + for (var i = 0; i < searchResultLength; i++) + if (searchResults[i] case final chat) chat.id: chat, + }; + if (_chatListStreamController.isClosed) return; + _chatListStreamController.add(searchResultMap); } /// Function to clear the search results and show the original chat list. void clearSearch() { if (_chatListStreamController.isClosed) return; - _chatListStreamController.sink.add(initialChatList); + _chatListStreamController.add(chatListMap); } /// Used to dispose ValueNotifiers and Streams. diff --git a/lib/src/extensions/extensions.dart b/lib/src/extensions/extensions.dart index 505c7195..1e7caad0 100644 --- a/lib/src/extensions/extensions.dart +++ b/lib/src/extensions/extensions.dart @@ -26,6 +26,7 @@ import 'package:intl/intl.dart'; import '../inherited_widgets/configurations_inherited_widgets.dart'; import '../models/config_models/chat_bubble_configuration.dart'; +import '../models/config_models/chat_view_list/list_type_indicator_config.dart'; import '../models/config_models/message_list_configuration.dart'; import '../models/config_models/reply_suggestions_config.dart'; import '../utils/constants/constants.dart'; @@ -151,6 +152,37 @@ extension ChatViewStateTitleExtension on String? { } } +extension type const TypingStatusConfigExtension(TypingStatusConfig config) { + String toTypingStatus(List users) { + final prefix = config.prefix ?? ''; + final suffix = config.suffix ?? ''; + final showUserNames = config.showUserNames; + final locale = PackageStrings.currentLocale; + final text = '$prefix${locale.typing}$suffix'; + if (users.isEmpty) return text; + + final count = users.length; + + final firstName = users[0]; + + if (count == 1) { + return '$firstName ${locale.isVerb} $text'; + } else if (count == 2) { + final newText = showUserNames + ? '$firstName & ${users[1]} ${locale.areVerb}' + : '$firstName & 1 ${locale.other} ${locale.isVerb}'; + return '$newText $text'; + } else if (showUserNames && count == 3) { + return '${users[0]}, ${users[1]} & ${users[2]} ${locale.areVerb} $text'; + } else { + final newText = showUserNames + ? '$firstName, ${users[1]} & ${count - 2}' + : '$firstName & ${count - 1}'; + return '$newText ${locale.others} ${locale.areVerb} $text'; + } + } +} + /// Extension on State for accessing inherited widget. extension StatefulWidgetExtension on State { ChatViewInheritedWidget? get chatViewIW => diff --git a/lib/src/models/chat_view_list_item.dart b/lib/src/models/chat_view_list_item.dart index c0eea64e..c700aa5e 100644 --- a/lib/src/models/chat_view_list_item.dart +++ b/lib/src/models/chat_view_list_item.dart @@ -22,6 +22,8 @@ import 'package:chatview_utils/chatview_utils.dart'; import '../values/enumeration.dart'; +import '../values/typedefs.dart'; +import 'omit.dart'; /// Model class representing a user or group in the chat list. class ChatViewListItem { @@ -30,6 +32,7 @@ class ChatViewListItem { required this.id, required this.name, this.chatType = ChatType.user, + this.typingUsers = const {}, this.userActiveStatus = UserActiveStatus.offline, this.lastMessage, this.imageUrl, @@ -60,4 +63,34 @@ class ChatViewListItem { /// /// Defaults to [UserActiveStatus.offline]. final UserActiveStatus userActiveStatus; + + // TODO(YASH): Switch to User Object instead of string. + /// Set of users currently typing in the chat. + final Set typingUsers; + + ChatViewListItem copyWith({ + Defaulted id = const Omit(), + Defaulted name = const Omit(), + Defaulted chatType = const Omit(), + Defaulted> typingUsers = const Omit(), + Defaulted userActiveStatus = const Omit(), + Defaulted? lastMessage = const Omit(), + Defaulted? imageUrl = const Omit(), + Defaulted? unreadCount = const Omit(), + }) { + return ChatViewListItem( + id: id is Omit ? this.id : id as String, + name: name is Omit ? this.name : name as String, + chatType: chatType is Omit ? this.chatType : chatType as ChatType, + typingUsers: + typingUsers is Omit ? this.typingUsers : typingUsers as Set, + userActiveStatus: userActiveStatus is Omit + ? this.userActiveStatus + : userActiveStatus as UserActiveStatus, + lastMessage: + lastMessage is Omit ? this.lastMessage : lastMessage as Message?, + imageUrl: imageUrl is Omit ? this.imageUrl : imageUrl as String?, + unreadCount: unreadCount is Omit ? this.unreadCount : unreadCount as int?, + ); + } } diff --git a/lib/src/models/config_models/chat_view_list/chat_view_list_config.dart b/lib/src/models/config_models/chat_view_list/chat_view_list_config.dart index 7620fda9..ec534a1a 100644 --- a/lib/src/models/config_models/chat_view_list/chat_view_list_config.dart +++ b/lib/src/models/config_models/chat_view_list/chat_view_list_config.dart @@ -29,6 +29,7 @@ import 'search_config.dart'; class ChatViewListConfig { /// Creates a configuration object for the chat list UI. const ChatViewListConfig({ + this.backgroundColor = Colors.white, this.enablePagination = false, this.loadMoreConfig = const LoadMoreConfig(), this.tileConfig = const ListTileConfig(), @@ -60,4 +61,7 @@ class ChatViewListConfig { /// /// Defaults to `false`. final bool enablePagination; + + /// Background color for the chat list. + final Color backgroundColor; } diff --git a/lib/src/models/config_models/chat_view_list/list_tile_config.dart b/lib/src/models/config_models/chat_view_list/list_tile_config.dart index 2f89841a..d0ded478 100644 --- a/lib/src/models/config_models/chat_view_list/list_tile_config.dart +++ b/lib/src/models/config_models/chat_view_list/list_tile_config.dart @@ -24,6 +24,7 @@ import 'package:flutter/material.dart'; import '../../../values/typedefs.dart'; import '../../chat_view_list_item.dart'; import 'last_message_time_config.dart'; +import 'list_type_indicator_config.dart'; import 'unread_count_config.dart'; import 'user_active_status_config.dart'; import 'user_avatar_config.dart'; @@ -37,6 +38,7 @@ class ListTileConfig { this.userAvatarConfig = const UserAvatarConfig(), this.unreadCountConfig = const UnreadCountConfig(), this.userActiveStatusConfig = const UserActiveStatusConfig(), + this.typingStatusConfig = const TypingStatusConfig(), this.userNameMaxLines = 1, this.userNameTextOverflow = TextOverflow.ellipsis, this.lastMessageMaxLines = 1, @@ -47,7 +49,7 @@ class ListTileConfig { this.lastMessageTextStyle, this.onTap, this.onLongPress, - this.customLastMessageListViewBuilder, + this.lastMessageTileBuilder, }); /// Padding around the widget in the chat list. @@ -113,5 +115,8 @@ class ListTileConfig { final bool showOnlineStatus; /// Custom builder for the last message view in the chat list. - final CustomLastMessageListViewBuilder? customLastMessageListViewBuilder; + final ChatViewListLastMessageTileBuilder? lastMessageTileBuilder; + + /// Configuration for the typing status indicator in the chat list. + final TypingStatusConfig typingStatusConfig; } diff --git a/lib/src/models/config_models/chat_view_list/list_type_indicator_config.dart b/lib/src/models/config_models/chat_view_list/list_type_indicator_config.dart new file mode 100644 index 00000000..e095d943 --- /dev/null +++ b/lib/src/models/config_models/chat_view_list/list_type_indicator_config.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; + +import '../../../values/typedefs.dart'; + +/// Configuration for the chat list type indicator. +/// Controls appearance and behavior of the indicator shown in chat list tiles. +class TypingStatusConfig { + /// Creates a [TypingStatusConfig]. + /// + /// Configuration options for the chat view list type indicator: + /// + /// - [suffix]: Appends a string to the indicator text. + /// Default is `null`. + /// - [maxLines]: Limits the number of lines for the indicator text. + /// Default is `1`. + /// - [showUserNames]: Toggles the display of user names in the indicator. + /// Default is `false`. + /// - [textStyle]: Customizes the style of the indicator text. + /// - [overflow]: Sets the text overflow behavior. + /// Default is `TextOverflow.ellipsis`. + /// - [prefix]: Prepends a string to the indicator text. + /// Default is `null`. + /// - [textBuilder]: Allows custom building of the indicator text. + /// Default is `null`. + /// - [widgetBuilder]: Allows custom building of the indicator widget. + /// Default is `null`. + const TypingStatusConfig({ + this.suffix = '...', + this.maxLines = 1, + this.showUserNames = false, + this.textStyle = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + fontStyle: FontStyle.italic, + ), + this.overflow = TextOverflow.ellipsis, + this.prefix, + this.textBuilder, + this.widgetBuilder, + }); + + /// Optional text appended after the typing indicator. + /// + /// The main indicator text comes from locale `typing`. + /// + /// Example: + /// - If `suffix = '...'`, + /// ➜ Display: `'typing...'` + /// - If `suffix = null`, + /// ➜ Display: `'typing'` + final String? suffix; + + /// Whether to show user names in the indicator. + /// + /// If `true`, the indicator will include user names. + /// Example: + /// - If `showUserNames = true` and there are two users typing, + /// ➜ Display: `'ChatView & Simform are typing...'` + /// - If `showUserNames = false`, + /// ➜ Display: `'ChatView & 1 other is typing...'` + final bool showUserNames; + + /// Optional text prepended before the typing indicator. + /// + /// The main indicator text comes from locale `typing`. + /// + /// Example: + /// - If `prefix = 'User'`, + /// ➜ Display: `'User typing...'` + /// - If `prefix = null`, + /// ➜ Display: `'typing...'` + final String? prefix; + + /// Style for the indicator text. + final TextStyle? textStyle; + + /// Maximum number of lines for the indicator text. + final int? maxLines; + + /// Overflow behavior for the indicator text. + final TextOverflow? overflow; + + /// Custom builder for indicator text. + final ChatViewListTextBuilder? textBuilder; + + /// Custom builder for indicator widget. + final ChatViewListWidgetBuilder? widgetBuilder; +} diff --git a/lib/src/models/config_models/chat_view_list/models.dart b/lib/src/models/config_models/chat_view_list/models.dart index 65c6f97d..991db224 100644 --- a/lib/src/models/config_models/chat_view_list/models.dart +++ b/lib/src/models/config_models/chat_view_list/models.dart @@ -22,6 +22,7 @@ export 'chat_view_list_config.dart'; export 'list_tile_config.dart'; export 'last_message_time_config.dart'; +export 'list_type_indicator_config.dart'; export 'load_more_config.dart'; export 'search_config.dart'; export 'unread_count_config.dart'; diff --git a/lib/src/models/config_models/chat_view_list/unread_count_config.dart b/lib/src/models/config_models/chat_view_list/unread_count_config.dart index ced8f5f4..64f4167a 100644 --- a/lib/src/models/config_models/chat_view_list/unread_count_config.dart +++ b/lib/src/models/config_models/chat_view_list/unread_count_config.dart @@ -23,15 +23,19 @@ import 'package:flutter/material.dart'; import '../../../utils/constants/constants.dart'; import '../../../values/enumeration.dart'; +import '../../../values/typedefs.dart'; /// Configuration class for the unread message widget in the chat list UI. class UnreadCountConfig { /// Creates a configuration object for the unread message widget in the chat list UI. const UnreadCountConfig({ + this.maxLines = 1, + this.textScaler = TextScaler.noScaling, this.style = UnreadCountStyle.dot, this.backgroundColor = primaryColor, this.textColor = Colors.white, this.fontSize = 12, + this.countWidgetBuilder, this.height, this.width, this.decoration, @@ -62,11 +66,24 @@ class UnreadCountConfig { /// Decoration for the unread count widget. final BoxDecoration? decoration; - /// View style for the unread count. + /// Style for the unread count in the user widget. /// /// Defaults to `UnreadCountStyle.dot`. final UnreadCountStyle style; /// Text styles for the unread count widget. final TextStyle? textStyle; + + /// Maximum number of lines for the unread count text. + /// + /// Defaults to `1`. + final int? maxLines; + + /// Custom text scaler for the unread count text. + /// + /// Defaults to `TextScaler.noScaling`. + final TextScaler? textScaler; + + /// Custom widget builder for the unread count widget. + final UnreadCountWidgetBuilder? countWidgetBuilder; } diff --git a/lib/src/models/omit.dart b/lib/src/models/omit.dart new file mode 100644 index 00000000..7ae124e3 --- /dev/null +++ b/lib/src/models/omit.dart @@ -0,0 +1,10 @@ +/// Omit is a sentinel class that implements `Future`: +/// (`Omit`) that means "don't change this field" +final class Omit implements Future { + const Omit(); + + @override + dynamic noSuchMethod(Invocation invocation) { + return super.noSuchMethod(invocation); + } +} diff --git a/lib/src/utils/chat_view_locale.dart b/lib/src/utils/chat_view_locale.dart index bd5414e4..4d4a7bde 100644 --- a/lib/src/utils/chat_view_locale.dart +++ b/lib/src/utils/chat_view_locale.dart @@ -44,6 +44,11 @@ final class ChatViewLocale { required this.now, required this.minAgo, required this.voice, + required this.typing, + required this.isVerb, + required this.areVerb, + required this.other, + required this.others, }); /// Create from Map @@ -70,6 +75,11 @@ final class ChatViewLocale { now: map['now']?.toString() ?? '', minAgo: map['minAgo']?.toString() ?? '', voice: map['voice']?.toString() ?? '', + typing: map['typing']?.toString() ?? '', + isVerb: map['isVerb']?.toString() ?? '', + areVerb: map['areVerb']?.toString() ?? '', + other: map['other']?.toString() ?? '', + others: map['others']?.toString() ?? '', ); } @@ -94,6 +104,11 @@ final class ChatViewLocale { final String now; final String minAgo; final String voice; + final String typing; + final String isVerb; + final String areVerb; + final String other; + final String others; /// English defaults static const en = ChatViewLocale( @@ -118,5 +133,10 @@ final class ChatViewLocale { now: 'Now', minAgo: 'min ago', voice: 'Voice', + typing: 'typing', + areVerb: 'are', + isVerb: 'is', + other: 'other', + others: 'others', ); } diff --git a/lib/src/values/typedefs.dart b/lib/src/values/typedefs.dart index 2116da6f..2620da1d 100644 --- a/lib/src/values/typedefs.dart +++ b/lib/src/values/typedefs.dart @@ -20,11 +20,15 @@ * SOFTWARE. */ +import 'dart:async'; + import 'package:chatview_utils/chatview_utils.dart'; import 'package:flutter/material.dart'; import '../models/chat_view_list_item.dart'; +typedef Defaulted = FutureOr; + typedef StringMessageCallBack = void Function( String message, ReplyMessage replyMessage, @@ -104,9 +108,15 @@ typedef BackgroundImageLoadError = void Function( Object exception, StackTrace? stackTrace, )?; -typedef SearchUserCallback = Future?> Function( +typedef SearchUserCallback = FutureOr?> Function( String value, ); -typedef CustomLastMessageListViewBuilder = Widget Function( +typedef ChatViewListLastMessageTileBuilder = Widget Function( Message? message, ); +typedef ChatViewListTextBuilder = String? Function(ChatViewListItem chat); +typedef ChatViewListWidgetBuilder = Widget? Function(ChatViewListItem chat); +typedef UpdateChatCallback = ChatViewListItem Function( + ChatViewListItem previousChat, +); +typedef UnreadCountWidgetBuilder = Widget Function(int count); diff --git a/lib/src/widgets/chat_view_list/chat_view_list.dart b/lib/src/widgets/chat_view_list/chat_view_list.dart index 4924d20a..ce8b571c 100644 --- a/lib/src/widgets/chat_view_list/chat_view_list.dart +++ b/lib/src/widgets/chat_view_list/chat_view_list.dart @@ -35,7 +35,9 @@ import 'search_text_field.dart'; class ChatViewList extends StatefulWidget { const ChatViewList({ required this.controller, + // TODO(YASH): take this as a callback rather than a bool this.isLastPage = false, + // TODO(YASH): remove the necessity for this. this.showSearchTextField = true, this.config = const ChatViewListConfig(), this.scrollViewKeyboardDismissBehavior = @@ -44,7 +46,6 @@ class ChatViewList extends StatefulWidget { this.trailing, this.userName, this.lastMessageTime, - this.unreadCount, this.chatListUserWidgetBuilder, this.appbar, this.loadMoreChats, @@ -70,9 +71,6 @@ class ChatViewList extends StatefulWidget { /// Provides widget for last message time in chat list. final Widget? lastMessageTime; - /// Provides widget for unread count in chat list. - final Widget? unreadCount; - /// Provides widget builder for users in chat list. final NullableIndexedWidgetBuilder? chatListUserWidgetBuilder; @@ -168,7 +166,6 @@ class _ChatViewListState extends State { trailing: widget.trailing, userName: widget.userName, lastMessageTime: widget.lastMessageTime, - unreadCount: widget.unreadCount, tileConfig: widget.config.tileConfig, ); }, diff --git a/lib/src/widgets/chat_view_list/chat_view_list_item_tile.dart b/lib/src/widgets/chat_view_list/chat_view_list_item_tile.dart index b04155bc..6a72890f 100644 --- a/lib/src/widgets/chat_view_list/chat_view_list_item_tile.dart +++ b/lib/src/widgets/chat_view_list/chat_view_list_item_tile.dart @@ -19,13 +19,14 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ -import 'package:chatview_utils/chatview_utils.dart'; import 'package:flutter/material.dart'; +import '../../extensions/extensions.dart'; import '../../models/chat_view_list_item.dart'; import '../../models/config_models/chat_view_list/list_tile_config.dart'; import '../../utils/helper.dart'; -import '../../utils/package_strings.dart'; +import 'last_message_view.dart'; +import 'unread_count_view.dart'; import 'user_avatar_view.dart'; class ChatViewListItemTile extends StatelessWidget { @@ -36,7 +37,6 @@ class ChatViewListItemTile extends StatelessWidget { this.userName, this.lastMessageTime, this.trailing, - this.unreadCount, super.key, }); @@ -58,13 +58,19 @@ class ChatViewListItemTile extends StatelessWidget { /// Provides widget for trailing elements in chat list. final Widget? trailing; - /// Provides widget for unread count in chat list. - final Widget? unreadCount; - @override Widget build(BuildContext context) { + final typingStatusConfig = tileConfig.typingStatusConfig; final unreadCountConfig = tileConfig.unreadCountConfig; final lastMessageTimeConfig = tileConfig.timeConfig; + final unreadCount = chat.unreadCount ?? 0; + final isAnyUserTyping = chat.typingUsers.isNotEmpty; + final showUnreadCount = + unreadCountConfig.countWidgetBuilder != null || unreadCount > 0; + final lastMessage = chat.lastMessage; + final typingStatusText = typingStatusConfig.textBuilder?.call(chat) ?? + TypingStatusConfigExtension(typingStatusConfig) + .toTypingStatus(chat.typingUsers.toList()); return GestureDetector( behavior: HitTestBehavior.opaque, onLongPress: () => tileConfig.onLongPress?.call(chat), @@ -90,6 +96,7 @@ class ChatViewListItemTile extends StatelessWidget { child: Padding( padding: tileConfig.middleWidgetPadding, child: Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ userName ?? @@ -103,70 +110,46 @@ class ChatViewListItemTile extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - if (chat.lastMessage?.message.isNotEmpty ?? false) - tileConfig.customLastMessageListViewBuilder - ?.call(chat.lastMessage) ?? - switch (chat.lastMessage?.messageType) { - MessageType.image => Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.photo, - size: 14, - ), - const SizedBox( - width: 5, - ), - Text( - PackageStrings.currentLocale.photo, - style: TextStyle( - fontWeight: chat.unreadCount != null && - chat.unreadCount! > 0 - ? FontWeight.bold - : FontWeight.normal, - ), - ), - ], - ), - MessageType.text => Text( - chat.lastMessage?.message ?? '', - maxLines: tileConfig.lastMessageMaxLines, - overflow: tileConfig.lastMessageTextOverflow, - style: tileConfig.lastMessageTextStyle ?? - TextStyle( - fontSize: 14, - fontWeight: chat.unreadCount != null && - chat.unreadCount! > 0 - ? FontWeight.bold - : FontWeight.normal, - ), - ), - MessageType.voice => Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.mic, - size: 14, - ), - const SizedBox( - width: 5, - ), - Text( - PackageStrings.currentLocale.voice, - ), - ], + if (isAnyUserTyping || lastMessage != null) + AnimatedSwitcher( + switchOutCurve: Curves.easeOut, + switchInCurve: Curves.easeIn, + duration: const Duration(milliseconds: 200), + reverseDuration: const Duration(milliseconds: 150), + child: isAnyUserTyping + ? typingStatusConfig.widgetBuilder?.call(chat) ?? + Text( + key: ValueKey(typingStatusText), + typingStatusText, + maxLines: typingStatusConfig.maxLines, + overflow: typingStatusConfig.overflow, + style: typingStatusConfig.textStyle, + ) + : LastMessageView( + key: lastMessage?.id.isEmpty ?? true + ? null + : ValueKey(lastMessage?.id), + unreadCount: unreadCount, + lastMessage: lastMessage, + lastMessageType: lastMessage?.messageType, + lastMessageMaxLines: + tileConfig.lastMessageMaxLines, + lastMessageTextOverflow: + tileConfig.lastMessageTextOverflow, + lastMessageTextStyle: + tileConfig.lastMessageTextStyle, + lastMessageBuilder: tileConfig + .lastMessageTileBuilder + ?.call(lastMessage), ), - // Provides the view for the custom message type in [customLastMessageListViewBuilder] - MessageType.custom || - null => - const SizedBox.shrink(), - } + ), ], ), ), ), trailing ?? Column( + mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [ if (lastMessageTime case final widget?) @@ -186,66 +169,12 @@ class ChatViewListItemTile extends StatelessWidget { height: lastMessageTimeConfig.spaceBetweenTimeAndUnreadCount, ), - if (unreadCount case final widget?) - widget - else if (chat.unreadCount != null && chat.unreadCount! > 0) - Builder( - builder: (context) { - if (unreadCountConfig.style.isDot) { - final dotSize = unreadCountConfig.width ?? 12; - return Container( - width: dotSize, - height: dotSize, - decoration: unreadCountConfig.decoration ?? - BoxDecoration( - color: unreadCountConfig.backgroundColor, - shape: BoxShape.circle, - ), - ); - } else if (unreadCountConfig.style.isNone) { - return const SizedBox.shrink(); - } else { - final displayCount = - ((unreadCountConfig.style.isNinetyNinePlus) && - chat.unreadCount! > 99) - ? '99+' - : '${chat.unreadCount}'; - final isSingleDigit = chat.unreadCount! < 10; - final minWidth = unreadCountConfig.width ?? 20; - final padding = isSingleDigit ? 0.0 : 4.0; - return Container( - constraints: BoxConstraints( - minWidth: minWidth, - minHeight: unreadCountConfig.height ?? 20, - ), - padding: EdgeInsets.symmetric( - horizontal: padding, - ), - decoration: unreadCountConfig.decoration ?? - BoxDecoration( - color: unreadCountConfig.backgroundColor, - shape: isSingleDigit - ? BoxShape.circle - : BoxShape.rectangle, - borderRadius: isSingleDigit - ? null - : BorderRadius.circular(minWidth), - ), - alignment: Alignment.center, - child: Text( - displayCount, - style: unreadCountConfig.textStyle ?? - TextStyle( - fontWeight: FontWeight.bold, - color: unreadCountConfig.textColor, - fontSize: unreadCountConfig.fontSize, - ), - textAlign: TextAlign.center, - ), - ); - } - }, - ), + if (showUnreadCount) + unreadCountConfig.countWidgetBuilder?.call(unreadCount) ?? + UnreadCountView( + unreadCount: unreadCount, + config: unreadCountConfig, + ), ], ), ], diff --git a/lib/src/widgets/chat_view_list/last_message_view.dart b/lib/src/widgets/chat_view_list/last_message_view.dart new file mode 100644 index 00000000..fb566119 --- /dev/null +++ b/lib/src/widgets/chat_view_list/last_message_view.dart @@ -0,0 +1,77 @@ +import 'package:chatview_utils/chatview_utils.dart'; +import 'package:flutter/material.dart'; + +import '../../utils/package_strings.dart'; + +class LastMessageView extends StatelessWidget { + const LastMessageView({ + required this.unreadCount, + required this.lastMessage, + this.lastMessageType, + this.lastMessageBuilder, + super.key, + this.lastMessageMaxLines, + this.lastMessageTextStyle, + this.lastMessageTextOverflow, + }) : assert( + !(lastMessageBuilder == null && + lastMessageType == MessageType.custom), + 'Ensure that lastMessageBuilder is provided for MessageType.custom' + ' to prevent the message preview from being empty.', + ); + + final int unreadCount; + final Message? lastMessage; + + final int? lastMessageMaxLines; + final TextStyle? lastMessageTextStyle; + final TextOverflow? lastMessageTextOverflow; + + /// Defined separately from config, as config properties + /// can't be used in assert with const constructor. + final MessageType? lastMessageType; + final Widget? lastMessageBuilder; + + @override + Widget build(BuildContext context) { + return lastMessageBuilder ?? + switch (lastMessageType) { + MessageType.image => Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.photo, size: 14), + const SizedBox(width: 5), + Text( + PackageStrings.currentLocale.photo, + style: TextStyle( + fontWeight: + unreadCount > 0 ? FontWeight.bold : FontWeight.normal, + ), + ), + ], + ), + MessageType.text => Text( + lastMessage?.message ?? '', + maxLines: lastMessageMaxLines, + overflow: lastMessageTextOverflow, + style: lastMessageTextStyle ?? + TextStyle( + fontSize: 14, + fontWeight: + unreadCount > 0 ? FontWeight.bold : FontWeight.normal, + ), + ), + MessageType.voice => Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.mic, size: 14), + const SizedBox(width: 5), + Text(PackageStrings.currentLocale.voice), + ], + ), + // Provides the view for the custom message type in + // `lastMessageTileBuilder` + MessageType.custom || null => const SizedBox.shrink(), + }; + } +} diff --git a/lib/src/widgets/chat_view_list/search_text_field.dart b/lib/src/widgets/chat_view_list/search_text_field.dart index 59fa2f64..78ac5b11 100644 --- a/lib/src/widgets/chat_view_list/search_text_field.dart +++ b/lib/src/widgets/chat_view_list/search_text_field.dart @@ -19,6 +19,8 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ +import 'dart:async'; + import 'package:flutter/material.dart'; import '../../controller/chat_list_view_controller.dart'; @@ -117,7 +119,7 @@ class _SearchTextFieldState extends State { widget.chatViewListController?.clearSearch(); } - void _onSearchChanged(String value) async { + FutureOr _onSearchChanged(String value) async { _inputText.value = value; final chatList = await _config.onSearch?.call(value); if (chatList != null) { diff --git a/lib/src/widgets/chat_view_list/unread_count_view.dart b/lib/src/widgets/chat_view_list/unread_count_view.dart new file mode 100644 index 00000000..b49f80af --- /dev/null +++ b/lib/src/widgets/chat_view_list/unread_count_view.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +import '../../models/config_models/chat_view_list/unread_count_config.dart'; +import '../../values/enumeration.dart'; + +class UnreadCountView extends StatelessWidget { + const UnreadCountView({ + required this.unreadCount, + required this.config, + super.key, + }); + + final int unreadCount; + final UnreadCountConfig config; + + @override + Widget build(BuildContext context) { + final isSingleDigit = unreadCount < 10; + final minWidth = config.width ?? 20; + final minHeight = config.height ?? 20; + return switch (config.style) { + UnreadCountStyle.none => const SizedBox.shrink(), + UnreadCountStyle.dot => SizedBox.square( + dimension: config.width ?? 12, + child: DecoratedBox( + decoration: config.decoration ?? + BoxDecoration( + color: config.backgroundColor, + shape: BoxShape.circle, + ), + ), + ), + UnreadCountStyle.count || UnreadCountStyle.ninetyNinePlus => Container( + alignment: Alignment.center, + constraints: BoxConstraints(minWidth: minWidth, minHeight: minHeight), + padding: + isSingleDigit ? null : const EdgeInsets.symmetric(horizontal: 4), + decoration: config.decoration ?? + BoxDecoration( + color: config.backgroundColor, + shape: isSingleDigit ? BoxShape.circle : BoxShape.rectangle, + borderRadius: + isSingleDigit ? null : BorderRadius.circular(minWidth), + ), + child: Text( + config.style.isNinetyNinePlus && unreadCount > 99 + ? '99+' + : '$unreadCount', + maxLines: config.maxLines, + textAlign: TextAlign.center, + textScaler: config.textScaler, + style: config.textStyle ?? + TextStyle( + color: config.textColor, + fontSize: config.fontSize, + fontWeight: FontWeight.bold, + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + ), + }; + } +}