feat(chat-time-indicator): add small time-indicator in chat detail screens

This commit is contained in:
Joey Boerwinkel 2025-03-06 15:35:30 +01:00 committed by FlutterJoey
parent 4e0967cc33
commit bcf2c0484b
13 changed files with 276 additions and 17 deletions

View file

@ -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 ## 5.0.0
- Removed the default values for the ChatOptions that are now nullable so they resolve to the ThemeData values - 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 - Added chatAlignment to change the alignment of the chat messages

View file

@ -1,6 +1,6 @@
name: chat_repository_interface name: chat_repository_interface
description: "The interface for a chat repository" description: "The interface for a chat repository"
version: 5.0.0 version: 5.1.0
homepage: "https://github.com/Iconica-Development" homepage: "https://github.com/Iconica-Development"
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/

View file

@ -1,6 +1,6 @@
name: firebase_chat_repository name: firebase_chat_repository
description: "Firebase repository implementation for the chat domain repository interface" description: "Firebase repository implementation for the chat domain repository interface"
version: 5.0.0 version: 5.1.0
homepage: "https://github.com/Iconica-Development" homepage: "https://github.com/Iconica-Development"
publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/ publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
@ -15,7 +15,7 @@ dependencies:
chat_repository_interface: chat_repository_interface:
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
version: ^5.0.0 version: ^5.1.0
firebase_storage: any firebase_storage: any
cloud_firestore: any cloud_firestore: any

View file

@ -8,6 +8,7 @@ export "package:flutter_chat/src/flutter_chat_navigator_userstories.dart";
// Options // Options
export "src/config/chat_builders.dart"; export "src/config/chat_builders.dart";
export "src/config/chat_options.dart"; export "src/config/chat_options.dart";
export "src/config/chat_time_indicator_options.dart";
export "src/config/chat_translations.dart"; export "src/config/chat_translations.dart";
export "src/config/screen_types.dart"; export "src/config/screen_types.dart";

View file

@ -1,9 +1,7 @@
import "package:cached_network_image/cached_network_image.dart"; 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/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_semantics.dart";
import "package:flutter_chat/src/config/chat_translations.dart";
/// The chat options /// The chat options
/// Use this class to configure the chat options. /// Use this class to configure the chat options.
@ -28,6 +26,7 @@ class ChatOptions {
this.onNoChats, this.onNoChats,
this.imageQuality = 20, this.imageQuality = 20,
this.imageProviderResolver = _defaultImageProviderResolver, this.imageProviderResolver = _defaultImageProviderResolver,
this.timeIndicatorOptions = const ChatTimeIndicatorOptions(),
ChatRepositoryInterface? chatRepository, ChatRepositoryInterface? chatRepository,
UserRepositoryInterface? userRepository, UserRepositoryInterface? userRepository,
}) : chatRepository = chatRepository ?? LocalChatRepository(), }) : chatRepository = chatRepository ?? LocalChatRepository(),
@ -109,6 +108,9 @@ class ChatOptions {
/// the images in the entire userstory. If not provided, CachedNetworkImage /// the images in the entire userstory. If not provided, CachedNetworkImage
/// will be used. /// will be used.
final ImageProviderResolver imageProviderResolver; 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 /// Typedef for the chatTitleResolver function that is used to get a title for

View file

@ -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);

View file

@ -4,6 +4,8 @@
// ignore_for_file: public_member_api_docs // ignore_for_file: public_member_api_docs
import "package:intl/intl.dart";
/// Class that holds all the translations for the chat component view and /// Class that holds all the translations for the chat component view and
/// the corresponding userstory /// the corresponding userstory
class ChatTranslations { class ChatTranslations {
@ -50,6 +52,7 @@ class ChatTranslations {
required this.groupNameEmpty, required this.groupNameEmpty,
required this.messagesLoadingError, required this.messagesLoadingError,
required this.next, required this.next,
required this.chatTimeIndicatorLabel,
}); });
/// Default translations for the chat component view /// Default translations for the chat component view
@ -95,6 +98,8 @@ class ChatTranslations {
this.groupNameEmpty = "Group", this.groupNameEmpty = "Group",
this.messagesLoadingError = "Error loading messages, you can reload below:", this.messagesLoadingError = "Error loading messages, you can reload below:",
this.next = "Next", this.next = "Next",
this.chatTimeIndicatorLabel =
ChatTranslations.defaultChatTimeIndicatorLabel,
}); });
final String chatsTitle; final String chatsTitle;
@ -140,6 +145,33 @@ class ChatTranslations {
/// to be loaded. /// to be loaded.
final String messagesLoadingError; 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; final String next;
// copyWith method to override the default values // copyWith method to override the default values
@ -182,6 +214,7 @@ class ChatTranslations {
String? groupNameEmpty, String? groupNameEmpty,
String? messagesLoadingError, String? messagesLoadingError,
String? next, String? next,
String Function(int dateOffset, DateTime time)? chatTimeIndicatorLabel,
}) => }) =>
ChatTranslations( ChatTranslations(
chatsTitle: chatsTitle ?? this.chatsTitle, chatsTitle: chatsTitle ?? this.chatsTitle,
@ -234,5 +267,7 @@ class ChatTranslations {
groupNameEmpty: groupNameEmpty ?? this.groupNameEmpty, groupNameEmpty: groupNameEmpty ?? this.groupNameEmpty,
messagesLoadingError: messagesLoadingError ?? this.messagesLoadingError, messagesLoadingError: messagesLoadingError ?? this.messagesLoadingError,
next: next ?? this.next, next: next ?? this.next,
chatTimeIndicatorLabel:
chatTimeIndicatorLabel ?? this.chatTimeIndicatorLabel,
); );
} }

View file

@ -1,15 +1,12 @@
import "dart:async"; import "dart:async";
import "dart:typed_data"; import "dart:typed_data";
import "package:chat_repository_interface/chat_repository_interface.dart";
import "package:flutter/material.dart"; import "package:flutter/material.dart";
import "package:flutter_accessibility/flutter_accessibility.dart"; import "package:flutter_accessibility/flutter_accessibility.dart";
import "package:flutter_chat/src/config/chat_options.dart"; import "package:flutter_chat/flutter_chat.dart";
import "package:flutter_chat/src/config/screen_types.dart";
import "package:flutter_chat/src/screens/chat_detail/widgets/chat_bottom.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/chat_detail/widgets/chat_widgets.dart";
import "package:flutter_chat/src/screens/creation/widgets/default_image_picker.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"; import "package:flutter_hooks/flutter_hooks.dart";
/// Chat detail screen /// Chat detail screen
@ -465,14 +462,26 @@ class _ChatBody extends HookWidget {
bubbleChildren bubbleChildren
.add(ChatNoMessages(isGroupChat: chat?.isGroupChat ?? false)); .add(ChatNoMessages(isGroupChat: chat?.isGroupChat ?? false));
} else { } else {
for (var (index, msg) in messages.indexed) { for (var (index, currentMessage) in messages.indexed) {
var prevMsg = index > 0 ? messages[index - 1] : null; var previousMessage = index > 0 ? messages[index - 1] : null;
if (options.timeIndicatorOptions.isMessageInNewTimeSection(
context,
previousMessage,
currentMessage,
)) {
bubbleChildren.add(
ChatTimeIndicator(
forDate: currentMessage.timestamp,
),
);
}
bubbleChildren.add( bubbleChildren.add(
ChatBubble( ChatBubble(
message: msg, message: currentMessage,
previousMessage: prevMsg, previousMessage: previousMessage,
sender: userMap[msg.senderId], sender: userMap[currentMessage.senderId],
onPressSender: onPressUserProfile, onPressSender: onPressUserProfile,
semanticIdTitle: options.semantics.chatBubbleTitle(index), semanticIdTitle: options.semantics.chatBubbleTitle(index),
semanticIdTime: options.semantics.chatBubbleTime(index), semanticIdTime: options.semantics.chatBubbleTime(index),

View file

@ -3,6 +3,7 @@ import "package:flutter/material.dart";
import "package:flutter_accessibility/flutter_accessibility.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/screens/chat_detail/widgets/default_message_builder.dart";
import "package:flutter_chat/src/util/scope.dart"; import "package:flutter_chat/src/util/scope.dart";
import "package:flutter_chat/src/util/utils.dart";
import "package:flutter_hooks/flutter_hooks.dart"; import "package:flutter_hooks/flutter_hooks.dart";
/// Widget displayed when there are no messages in the chat. /// 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);
}
}

View file

@ -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,
),
),
),
);
}
}

View file

@ -1 +1,18 @@
// add generic utils that are used in the package // 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;
}

View file

@ -1,6 +1,6 @@
name: flutter_chat name: flutter_chat
description: "User story of the chat domain for quick integration into flutter apps" 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/ publish_to: https://forgejo.internal.iconica.nl/api/packages/internal/pub/
environment: environment:
@ -26,7 +26,7 @@ dependencies:
version: ^1.6.0 version: ^1.6.0
chat_repository_interface: chat_repository_interface:
hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub hosted: https://forgejo.internal.iconica.nl/api/packages/internal/pub
version: ^5.0.0 version: ^5.1.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

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