From bcf2c0484be56ba9a5665ab0e3e9a8929ccb9224 Mon Sep 17 00:00:00 2001 From: Joey Boerwinkel Date: Thu, 6 Mar 2025 15:35:30 +0100 Subject: [PATCH] feat(chat-time-indicator): add small time-indicator in chat detail screens --- CHANGELOG.md | 3 + .../chat_repository_interface/pubspec.yaml | 2 +- .../firebase_chat_repository/pubspec.yaml | 4 +- packages/flutter_chat/lib/flutter_chat.dart | 1 + .../lib/src/config/chat_options.dart | 8 +- .../config/chat_time_indicator_options.dart | 105 ++++++++++++++++++ .../lib/src/config/chat_translations.dart | 35 ++++++ .../chat_detail/chat_detail_screen.dart | 27 +++-- .../chat_detail/widgets/chat_widgets.dart | 30 +++++ .../widgets/default_chat_time_indicator.dart | 43 +++++++ packages/flutter_chat/lib/src/util/utils.dart | 17 +++ packages/flutter_chat/pubspec.yaml | 4 +- .../flutter_chat/test/relative_date_test.dart | 14 +++ 13 files changed, 276 insertions(+), 17 deletions(-) create mode 100644 packages/flutter_chat/lib/src/config/chat_time_indicator_options.dart create mode 100644 packages/flutter_chat/lib/src/screens/chat_detail/widgets/default_chat_time_indicator.dart create mode 100644 packages/flutter_chat/test/relative_date_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e7c259..57c1251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +## 5.1.0 +- Added optional time indicator in chat detail screens to show which day the message is posted + ## 5.0.0 - Removed the default values for the ChatOptions that are now nullable so they resolve to the ThemeData values - Added chatAlignment to change the alignment of the chat messages diff --git a/packages/chat_repository_interface/pubspec.yaml b/packages/chat_repository_interface/pubspec.yaml index 9684692..fa624a1 100644 --- a/packages/chat_repository_interface/pubspec.yaml +++ b/packages/chat_repository_interface/pubspec.yaml @@ -1,6 +1,6 @@ name: chat_repository_interface description: "The interface for a chat repository" -version: 5.0.0 +version: 5.1.0 homepage: "https://github.com/Iconica-Development" publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ diff --git a/packages/firebase_chat_repository/pubspec.yaml b/packages/firebase_chat_repository/pubspec.yaml index 1341664..80d28c1 100644 --- a/packages/firebase_chat_repository/pubspec.yaml +++ b/packages/firebase_chat_repository/pubspec.yaml @@ -1,6 +1,6 @@ name: firebase_chat_repository description: "Firebase repository implementation for the chat domain repository interface" -version: 5.0.0 +version: 5.1.0 homepage: "https://github.com/Iconica-Development" publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ @@ -15,7 +15,7 @@ dependencies: chat_repository_interface: hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub - version: ^5.0.0 + version: ^5.1.0 firebase_storage: any cloud_firestore: any diff --git a/packages/flutter_chat/lib/flutter_chat.dart b/packages/flutter_chat/lib/flutter_chat.dart index dd081f9..5380084 100644 --- a/packages/flutter_chat/lib/flutter_chat.dart +++ b/packages/flutter_chat/lib/flutter_chat.dart @@ -8,6 +8,7 @@ export "package:flutter_chat/src/flutter_chat_navigator_userstories.dart"; // Options export "src/config/chat_builders.dart"; export "src/config/chat_options.dart"; +export "src/config/chat_time_indicator_options.dart"; export "src/config/chat_translations.dart"; export "src/config/screen_types.dart"; diff --git a/packages/flutter_chat/lib/src/config/chat_options.dart b/packages/flutter_chat/lib/src/config/chat_options.dart index c1c083c..4ee7526 100644 --- a/packages/flutter_chat/lib/src/config/chat_options.dart +++ b/packages/flutter_chat/lib/src/config/chat_options.dart @@ -1,9 +1,7 @@ import "package:cached_network_image/cached_network_image.dart"; -import "package:chat_repository_interface/chat_repository_interface.dart"; import "package:flutter/material.dart"; -import "package:flutter_chat/src/config/chat_builders.dart"; +import "package:flutter_chat/flutter_chat.dart"; import "package:flutter_chat/src/config/chat_semantics.dart"; -import "package:flutter_chat/src/config/chat_translations.dart"; /// The chat options /// Use this class to configure the chat options. @@ -28,6 +26,7 @@ class ChatOptions { this.onNoChats, this.imageQuality = 20, this.imageProviderResolver = _defaultImageProviderResolver, + this.timeIndicatorOptions = const ChatTimeIndicatorOptions(), ChatRepositoryInterface? chatRepository, UserRepositoryInterface? userRepository, }) : chatRepository = chatRepository ?? LocalChatRepository(), @@ -109,6 +108,9 @@ class ChatOptions { /// the images in the entire userstory. If not provided, CachedNetworkImage /// will be used. final ImageProviderResolver imageProviderResolver; + + /// Options regarding the time indicator in chat screens + final ChatTimeIndicatorOptions timeIndicatorOptions; } /// Typedef for the chatTitleResolver function that is used to get a title for diff --git a/packages/flutter_chat/lib/src/config/chat_time_indicator_options.dart b/packages/flutter_chat/lib/src/config/chat_time_indicator_options.dart new file mode 100644 index 0000000..e1f2045 --- /dev/null +++ b/packages/flutter_chat/lib/src/config/chat_time_indicator_options.dart @@ -0,0 +1,105 @@ +import "package:flutter/material.dart"; +import "package:flutter_chat/flutter_chat.dart"; +import "package:flutter_chat/src/screens/chat_detail/widgets/default_chat_time_indicator.dart"; + +/// All options related to the time indicator +class ChatTimeIndicatorOptions { + /// Create default ChatTimeIndicator options + const ChatTimeIndicatorOptions({ + this.indicatorBuilder = DefaultChatTimeIndicator.builder, + this.labelResolver = defaultChatTimeIndicatorLabelResolver, + this.sectionCheck = defaultChatTimeIndicatorSectionChecker, + }); + + /// This completely disables the chat time indicator feature + const ChatTimeIndicatorOptions.none() + : indicatorBuilder = DefaultChatTimeIndicator.builder, + labelResolver = defaultChatTimeIndicatorLabelResolver, + sectionCheck = neverShowChatTimeIndicatorSectionChecker; + + /// The general builder for the indicator + final ChatTimeIndicatorBuilder indicatorBuilder; + + /// A function that translates offset / time to a string label + final ChatTimeIndicatorLabelResolver labelResolver; + + /// A function that determines when a new section starts + /// + /// By default, all messages are prefixed with a message. + /// You can disable this using the [skipFirstChatTimeIndicatorSectionChecker] + /// instead of the default, which would skip the first section + final ChatTimeIndicatorSectionChecker sectionCheck; + + /// public method on the options for readability + bool isMessageInNewTimeSection( + BuildContext context, + MessageModel? previousMessage, + MessageModel currentMessage, + ) => + sectionCheck( + context, + previousMessage, + currentMessage, + ); +} + +/// A function that would generate a string given the current window/datetime +typedef ChatTimeIndicatorLabelResolver = String Function( + BuildContext context, + int dayOffset, + DateTime currentWindow, +); + +/// A function that would determine if a chat indicator has to render +typedef ChatTimeIndicatorSectionChecker = bool Function( + BuildContext context, + MessageModel? previousMessage, + MessageModel currentMessage, +); + +/// Build used to render time indicators on chat detail screens +typedef ChatTimeIndicatorBuilder = Widget Function( + BuildContext context, + String timeLabel, +); + +/// +String defaultChatTimeIndicatorLabelResolver( + BuildContext context, + int dayOffset, + DateTime currentWindow, +) { + var translations = ChatScope.of(context).options.translations; + return translations.chatTimeIndicatorLabel(dayOffset, currentWindow); +} + +/// A function that disables the time indicator in chat +bool neverShowChatTimeIndicatorSectionChecker( + BuildContext context, + MessageModel? previousMessage, + MessageModel currentMessage, +) => + false; + +/// Variant of the default implementation for determining if a new section +/// starts, where the first section is skipped. +/// +/// Renders a new indicator every new section, skipping the first section +bool skipFirstChatTimeIndicatorSectionChecker( + BuildContext context, + MessageModel? previousMessage, + MessageModel currentMessage, +) => + previousMessage != null && + previousMessage.timestamp.date.isBefore(currentMessage.timestamp.date); + +/// Default implementation for determining if a new section starts. +/// +/// Renders a new indicator every new section +bool defaultChatTimeIndicatorSectionChecker( + BuildContext context, + MessageModel? previousMessage, + MessageModel currentMessage, +) => + previousMessage == null || + previousMessage.timestamp.date.isBefore(currentMessage.timestamp.date); diff --git a/packages/flutter_chat/lib/src/config/chat_translations.dart b/packages/flutter_chat/lib/src/config/chat_translations.dart index 9685c59..1a2c6de 100644 --- a/packages/flutter_chat/lib/src/config/chat_translations.dart +++ b/packages/flutter_chat/lib/src/config/chat_translations.dart @@ -4,6 +4,8 @@ // ignore_for_file: public_member_api_docs +import "package:intl/intl.dart"; + /// Class that holds all the translations for the chat component view and /// the corresponding userstory class ChatTranslations { @@ -50,6 +52,7 @@ class ChatTranslations { required this.groupNameEmpty, required this.messagesLoadingError, required this.next, + required this.chatTimeIndicatorLabel, }); /// Default translations for the chat component view @@ -95,6 +98,8 @@ class ChatTranslations { this.groupNameEmpty = "Group", this.messagesLoadingError = "Error loading messages, you can reload below:", this.next = "Next", + this.chatTimeIndicatorLabel = + ChatTranslations.defaultChatTimeIndicatorLabel, }); final String chatsTitle; @@ -140,6 +145,33 @@ class ChatTranslations { /// to be loaded. final String messagesLoadingError; + /// The message of a label given a certain offset. + /// + /// The offset determines whether it is today (0), yesterday (-1), or earlier. + /// + /// [dateOffset] will rarely be a +1, however if anyone ever wants to see + /// future chat messages, then this number will be positive. + /// + /// use the given [time] format to display exact time information. + final String Function(int dateOffset, DateTime time) chatTimeIndicatorLabel; + + /// Standard function to convert an offset to a String. + /// + /// Recommended to always override this in any production app with an + /// app localizations implementation. + static String defaultChatTimeIndicatorLabel( + int dateOffset, + DateTime time, + ) => + switch (dateOffset) { + 0 => "Today", + -1 => "Yesterday", + 1 => "Tomorrow", + int value when value < 5 && value > 1 => "In $value days", + int value when value < -1 && value > -5 => "$value days ago", + _ => DateFormat("dd-MM-YYYY").format(time), + }; + final String next; // copyWith method to override the default values @@ -182,6 +214,7 @@ class ChatTranslations { String? groupNameEmpty, String? messagesLoadingError, String? next, + String Function(int dateOffset, DateTime time)? chatTimeIndicatorLabel, }) => ChatTranslations( chatsTitle: chatsTitle ?? this.chatsTitle, @@ -234,5 +267,7 @@ class ChatTranslations { groupNameEmpty: groupNameEmpty ?? this.groupNameEmpty, messagesLoadingError: messagesLoadingError ?? this.messagesLoadingError, next: next ?? this.next, + chatTimeIndicatorLabel: + chatTimeIndicatorLabel ?? this.chatTimeIndicatorLabel, ); } diff --git a/packages/flutter_chat/lib/src/screens/chat_detail/chat_detail_screen.dart b/packages/flutter_chat/lib/src/screens/chat_detail/chat_detail_screen.dart index 74b4784..c2595c9 100644 --- a/packages/flutter_chat/lib/src/screens/chat_detail/chat_detail_screen.dart +++ b/packages/flutter_chat/lib/src/screens/chat_detail/chat_detail_screen.dart @@ -1,15 +1,12 @@ import "dart:async"; import "dart:typed_data"; -import "package:chat_repository_interface/chat_repository_interface.dart"; import "package:flutter/material.dart"; import "package:flutter_accessibility/flutter_accessibility.dart"; -import "package:flutter_chat/src/config/chat_options.dart"; -import "package:flutter_chat/src/config/screen_types.dart"; +import "package:flutter_chat/flutter_chat.dart"; import "package:flutter_chat/src/screens/chat_detail/widgets/chat_bottom.dart"; import "package:flutter_chat/src/screens/chat_detail/widgets/chat_widgets.dart"; import "package:flutter_chat/src/screens/creation/widgets/default_image_picker.dart"; -import "package:flutter_chat/src/util/scope.dart"; import "package:flutter_hooks/flutter_hooks.dart"; /// Chat detail screen @@ -465,14 +462,26 @@ class _ChatBody extends HookWidget { bubbleChildren .add(ChatNoMessages(isGroupChat: chat?.isGroupChat ?? false)); } else { - for (var (index, msg) in messages.indexed) { - var prevMsg = index > 0 ? messages[index - 1] : null; + for (var (index, currentMessage) in messages.indexed) { + var previousMessage = index > 0 ? messages[index - 1] : null; + + if (options.timeIndicatorOptions.isMessageInNewTimeSection( + context, + previousMessage, + currentMessage, + )) { + bubbleChildren.add( + ChatTimeIndicator( + forDate: currentMessage.timestamp, + ), + ); + } bubbleChildren.add( ChatBubble( - message: msg, - previousMessage: prevMsg, - sender: userMap[msg.senderId], + message: currentMessage, + previousMessage: previousMessage, + sender: userMap[currentMessage.senderId], onPressSender: onPressUserProfile, semanticIdTitle: options.semantics.chatBubbleTitle(index), semanticIdTime: options.semantics.chatBubbleTime(index), diff --git a/packages/flutter_chat/lib/src/screens/chat_detail/widgets/chat_widgets.dart b/packages/flutter_chat/lib/src/screens/chat_detail/widgets/chat_widgets.dart index 66057a6..453a6ad 100644 --- a/packages/flutter_chat/lib/src/screens/chat_detail/widgets/chat_widgets.dart +++ b/packages/flutter_chat/lib/src/screens/chat_detail/widgets/chat_widgets.dart @@ -3,6 +3,7 @@ import "package:flutter/material.dart"; import "package:flutter_accessibility/flutter_accessibility.dart"; import "package:flutter_chat/src/screens/chat_detail/widgets/default_message_builder.dart"; import "package:flutter_chat/src/util/scope.dart"; +import "package:flutter_chat/src/util/utils.dart"; import "package:flutter_hooks/flutter_hooks.dart"; /// Widget displayed when there are no messages in the chat. @@ -102,3 +103,32 @@ class ChatBubble extends HookWidget { ); } } + +/// The indicator above a set of messages, shown per date. +class ChatTimeIndicator extends StatelessWidget { + /// Creates a ChatTimeIndicator + const ChatTimeIndicator({ + required this.forDate, + super.key, + }); + + /// The dateTime at which the new time section starts + final DateTime forDate; + + @override + Widget build(BuildContext context) { + var scope = ChatScope.of(context); + var indicatorOptions = scope.options.timeIndicatorOptions; + + var today = DateTime.now(); + var differenceInDays = today.getDateOffsetInDays(forDate); + + var message = indicatorOptions.labelResolver( + context, + differenceInDays, + forDate, + ); + + return indicatorOptions.indicatorBuilder(context, message); + } +} diff --git a/packages/flutter_chat/lib/src/screens/chat_detail/widgets/default_chat_time_indicator.dart b/packages/flutter_chat/lib/src/screens/chat_detail/widgets/default_chat_time_indicator.dart new file mode 100644 index 0000000..6d3ea69 --- /dev/null +++ b/packages/flutter_chat/lib/src/screens/chat_detail/widgets/default_chat_time_indicator.dart @@ -0,0 +1,43 @@ +import "package:flutter/material.dart"; +import "package:flutter_chat/flutter_chat.dart"; + +/// The default layout for a chat indicator +class DefaultChatTimeIndicator extends StatelessWidget { + /// Create a default timeindicator in a chat + const DefaultChatTimeIndicator({ + required this.timeIndicatorString, + super.key, + }); + + /// The text shown in the time indicator + final String timeIndicatorString; + + /// Standard builder for time indication + static Widget builder(BuildContext context, String timeIndicatorString) => + DefaultChatTimeIndicator(timeIndicatorString: timeIndicatorString); + + @override + Widget build(BuildContext context) { + var theme = Theme.of(context); + var spacing = ChatScope.of(context).options.spacing; + return Center( + child: Container( + margin: EdgeInsets.only(top: spacing.chatBetweenMessagesPadding), + padding: const EdgeInsets.symmetric( + vertical: 4, + horizontal: 8, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6), + color: theme.colorScheme.surfaceContainerHighest, + ), + child: Text( + timeIndicatorString, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } +} diff --git a/packages/flutter_chat/lib/src/util/utils.dart b/packages/flutter_chat/lib/src/util/utils.dart index 5312e4f..ab2f66d 100644 --- a/packages/flutter_chat/lib/src/util/utils.dart +++ b/packages/flutter_chat/lib/src/util/utils.dart @@ -1 +1,18 @@ // add generic utils that are used in the package + +/// Extension to simplify detecting how many days relative dates are +extension RelativeDates on DateTime { + /// Strips timezone information whilst keeping the exact same date + DateTime get utcDate => DateTime.utc(year, month, day); + + /// Strips time information from the date + DateTime get date => DateTime(year, month, day); + + /// Get relative date in offset from the current position. + /// + /// `today.getDateOffsetInDays(yesterday)` would result in `-1` + /// + /// `yesterday.getDateOffsetInDays(tomorrow)` would result in `2` + int getDateOffsetInDays(DateTime other) => + other.utcDate.difference(utcDate).inDays; +} diff --git a/packages/flutter_chat/pubspec.yaml b/packages/flutter_chat/pubspec.yaml index 8989584..e457094 100644 --- a/packages/flutter_chat/pubspec.yaml +++ b/packages/flutter_chat/pubspec.yaml @@ -1,6 +1,6 @@ name: flutter_chat description: "User story of the chat domain for quick integration into flutter apps" -version: 5.0.0 +version: 5.1.0 publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ environment: @@ -26,7 +26,7 @@ dependencies: version: ^1.6.0 chat_repository_interface: hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub - version: ^5.0.0 + version: ^5.1.0 dev_dependencies: flutter_test: diff --git a/packages/flutter_chat/test/relative_date_test.dart b/packages/flutter_chat/test/relative_date_test.dart new file mode 100644 index 0000000..e77f361 --- /dev/null +++ b/packages/flutter_chat/test/relative_date_test.dart @@ -0,0 +1,14 @@ +import "package:flutter_chat/src/util/utils.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + group("RelativeDates", () { + test("getDateOffsetInDays", () { + var dateA = DateTime(2024, 10, 30); + var dateB = DateTime(2024, 10, 01); + + expect(dateA.getDateOffsetInDays(dateB), equals(29)); + expect(dateB.getDateOffsetInDays(dateA), equals(-29)); + }); + }); +}