diff --git a/CHANGELOG.md b/CHANGELOG.md index ffe194ed..91a6a660 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ * **Feat**: [454](https://github.com/SimformSolutionsPvtLtd/chatview/pull/454) :sparkles: Add configurable image fit option for AdaptiveImage widget * **Feat**: [457](https://github.com/SimformSolutionsPvtLtd/chatview/pull/457) :sparkles: Add errorWidget support to ImageMessageConfiguration * **Fix**: [458](https://github.com/SimformSolutionsPvtLtd/chatview/pull/458) Enhance text field action animations for improved UX +* **Refactor**: [455](https://github.com/SimformSolutionsPvtLtd/chatview/pull/455) Rework sending receipt animation with different transition ## 3.1.0 diff --git a/doc/documentation.md b/doc/documentation.md index 7ea2e6d5..9e0fa814 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -738,6 +738,14 @@ ChatView( fontWeight: FontWeight.w500, ), ), + receiptsWidgetConfig: ReceiptsWidgetConfig( + // Animation style for the pending → sent transition. + // SendingMessageAnimationType.slideOut (default), .textLabel, or .clockToTick. + sendingAnimationType: SendingMessageAnimationType.clockToTick, + // Color of the sending indicator (currently honored by clockToTick). + sendingIndicatorColor: Colors.white70, +), + ), inComingChatBubbleConfig: // Similar to outgoingChatBubbleConfig ), // ... diff --git a/lib/src/models/config_models/receipts_widget_config.dart b/lib/src/models/config_models/receipts_widget_config.dart index b2f27916..f156950b 100644 --- a/lib/src/models/config_models/receipts_widget_config.dart +++ b/lib/src/models/config_models/receipts_widget_config.dart @@ -20,6 +20,8 @@ * SOFTWARE. */ +import 'package:flutter/material.dart'; + import '../../utils/constants/constants.dart'; import '../../values/enumeration.dart'; import '../../values/typedefs.dart'; @@ -29,6 +31,8 @@ class ReceiptsWidgetConfig { this.receiptsBuilder, this.lastSeenAgoBuilder, this.showReceiptsIn = ShowReceiptsIn.lastMessage, + this.sendingAnimationType = SendingMessageAnimationType.slideOut, + this.sendingIndicatorColor, }); /// The builder that builds widget that right next to the senders message bubble. @@ -44,4 +48,16 @@ class ReceiptsWidgetConfig { /// Whether to show receipts in all messages or not defaults to [ShowReceiptsIn.lastMessage] final ShowReceiptsIn showReceiptsIn; + + /// The animation style used by the default [sendMessageAnimationBuilder] for + /// the pending → sent transition next to an outgoing bubble. + /// + /// Ignored when a custom [receiptsBuilder] is provided. + /// Defaults to [SendingMessageAnimationType.slideOut]. + final SendingMessageAnimationType sendingAnimationType; + + /// Color of the sending indicator. Currently honored only by + /// [SendingMessageAnimationType.clockToTick] (clock / single tick / double + /// tick). When null a default grey is used. + final Color? sendingIndicatorColor; } diff --git a/lib/src/utils/constants/constants.dart b/lib/src/utils/constants/constants.dart index 7f6090a6..e459f391 100644 --- a/lib/src/utils/constants/constants.dart +++ b/lib/src/utils/constants/constants.dart @@ -23,6 +23,7 @@ import 'package:chatview_utils/chatview_utils.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import '../../values/enumeration.dart'; import '../../widgets/chat_message_sending_to_sent_animation.dart'; import '../timeago/timeago.dart' as timeago; @@ -66,6 +67,30 @@ const double loadMoreCircularProgressIndicatorSize = 36; const String httpScheme = 'http'; const String httpsScheme = 'https'; +/// Diameter of the sending-status indicator circle. +const double sendingIndicatorSize = 6; + +/// Default color of the sending-status indicator (dot / text / clock-tick) +/// when no explicit color is provided. +const Color sendingIndicatorDefaultColor = Color(0xFF666f79); + +/// Duration of the sending-to-sent indicator slide animation. +const Duration sendingAnimationDuration = Duration(milliseconds: 400); + +/// Curve of the sending-to-sent indicator slide animation. +const Curve sendingAnimationCurve = Curves.easeOut; + +/// Size of the clock/tick icon for the WhatsApp-style sending indicator. +const double sendingTickIconSize = 14; + +/// Vertical space reserved at the bottom of a text bubble for the inline +/// WhatsApp-style clock/tick (and timestamp) so it never overlaps the message. +const double sendingReceiptInBubbleReservedHeight = 18; + +/// Text shown by the [SendingMessageAnimationType.textLabel] indicator while a +/// message is pending. +const String sendingLabelText = 'Sending...'; + String applicationDateFormatter(DateTime inputTime) { if (DateTime.now().difference(inputTime).inDays <= 3) { return timeago.format(inputTime); @@ -83,8 +108,12 @@ const String urlRegex = /// is not sent or at the pending state. A custom implementation can have different /// widgets for different states. /// Right now it is implemented to appear right next to the outgoing bubble. -Widget sendMessageAnimationBuilder(MessageStatus status) { - return SendingMessageAnimatingWidget(status); +Widget sendMessageAnimationBuilder( + MessageStatus status, { + SendingMessageAnimationType type = SendingMessageAnimationType.slideOut, + Color? color, +}) { + return SendingMessageAnimatingWidget(status, type: type, color: color); } /// Default builder when the message has got seen as of now diff --git a/lib/src/values/enumeration.dart b/lib/src/values/enumeration.dart index c84a3030..b9e18560 100644 --- a/lib/src/values/enumeration.dart +++ b/lib/src/values/enumeration.dart @@ -47,6 +47,45 @@ extension ChatViewStateExtension on ChatViewState { bool get noMessages => this == ChatViewState.noData; } +/// Defines how the sending → sent receipt transition is animated next to an +/// outgoing chat bubble. +enum SendingMessageAnimationType { + /// Default. A small dot/circle indicator that slides out to the right once + /// the message transitions from pending to sent. + slideOut, + + /// A minimal "Sending..." text label (no surrounding padding) that fades and + /// collapses away once the message is sent. + textLabel, + + /// WhatsApp-style. A clock icon shown while the message is pending that + /// cross-fades into a tick (check) once the message is sent. + /// + /// For text messages it renders inline at the bubble's bottom-right (next to + /// the timestamp), per-message, so [ShowReceiptsIn.lastMessage] does not + /// limit it — every outgoing bubble shows its own tick, matching the + /// WhatsApp pattern. For non-text messages it renders beside the bubble via + /// the standard receipt slot. The [ReceiptsWidgetConfig.receiptsBuilder] + /// override applies only to that beside-bubble slot, not the inline tick. + clockToTick; + + /// Returns true if the animation type is [slideOut]. + bool get isSlideOut => this == slideOut; + + /// Returns true if the animation type is [textLabel]. + bool get isTextLabel => this == textLabel; + + /// Returns true if the animation type is [clockToTick]. + bool get isClockToTick => this == clockToTick; + + /// Human-readable label, handy for settings/menus. + String get label => switch (this) { + slideOut => 'Slide-out dot', + textLabel => 'Sending text', + clockToTick => 'Clock to tick', + }; +} + enum GroupedListOrder { asc, desc } extension GroupedListOrderExtension on GroupedListOrder { diff --git a/lib/src/widgets/chat_bubble_widget.dart b/lib/src/widgets/chat_bubble_widget.dart index e4494603..5fba2119 100644 --- a/lib/src/widgets/chat_bubble_widget.dart +++ b/lib/src/widgets/chat_bubble_widget.dart @@ -183,7 +183,6 @@ class _ChatBubbleWidgetState extends State { Expanded( child: _messagesWidgetColumn(messagedUser), ), - if (isMessageBySender) ...[getReceipt()], if (isMessageBySender && (featureActiveConfig?.enableCurrentUserProfileAvatar ?? true)) widget.isLastInGroup @@ -246,6 +245,14 @@ class _ChatBubbleWidgetState extends State { } } + SendingMessageAnimationType get sendingAnimationType => + chatListConfig.chatBubbleConfig?.outgoingChatBubbleConfig + ?.receiptsWidgetConfig?.sendingAnimationType ?? + SendingMessageAnimationType.slideOut; + + Color? get sendingIndicatorColor => chatListConfig.chatBubbleConfig + ?.outgoingChatBubbleConfig?.receiptsWidgetConfig?.sendingIndicatorColor; + Widget getReceipt() { final showReceipts = chatListConfig.chatBubbleConfig ?.outgoingChatBubbleConfig?.receiptsWidgetConfig?.showReceiptsIn ?? @@ -261,7 +268,8 @@ class _ChatBubbleWidgetState extends State { return chatListConfig.chatBubbleConfig?.outgoingChatBubbleConfig ?.receiptsWidgetConfig?.receiptsBuilder ?.call(value) ?? - sendMessageAnimationBuilder(value); + sendMessageAnimationBuilder(value, + type: sendingAnimationType, color: sendingIndicatorColor); } return const SizedBox(); }, @@ -278,9 +286,12 @@ class _ChatBubbleWidgetState extends State { return chatListConfig.chatBubbleConfig?.outgoingChatBubbleConfig ?.receiptsWidgetConfig?.receiptsBuilder ?.call(value) ?? - sendMessageAnimationBuilder(value); + sendMessageAnimationBuilder(value, + type: sendingAnimationType, + color: sendingIndicatorColor); } - return sendMessageAnimationBuilder(value); + return sendMessageAnimationBuilder(value, + type: sendingAnimationType, color: sendingIndicatorColor); }); } return const SizedBox(); @@ -347,50 +358,75 @@ class _ChatBubbleWidgetState extends State { ), ), ), - SwipeToReply( - isMessageByCurrentUser: isMessageBySender, - onSwipe: isMessageBySender ? onLeftSwipe : onRightSwipe, - child: MessageView( - outgoingChatBubbleConfig: - chatListConfig.chatBubbleConfig?.outgoingChatBubbleConfig, - isLongPressEnable: - (featureActiveConfig?.enableReactionPopup ?? true) || - (featureActiveConfig?.enableReplySnackBar ?? true), - inComingChatBubbleConfig: - chatListConfig.chatBubbleConfig?.inComingChatBubbleConfig, - message: widget.message, - isMessageBySender: isMessageBySender, - messageConfig: chatListConfig.messageConfig, - onLongPress: widget.onLongPress, - chatBubbleMaxWidth: chatListConfig.chatBubbleConfig?.maxWidth, - longPressAnimationDuration: - chatListConfig.chatBubbleConfig?.longPressAnimationDuration, - onDoubleTap: featureActiveConfig?.enableDoubleTapToLike ?? false - ? chatListConfig.chatBubbleConfig?.onDoubleTap ?? - (message) => currentUser != null - ? chatController?.setReaction( - emoji: heart, - messageId: message.id, - userId: currentUser!.id, - ) - : null - : null, - shouldHighlight: widget.shouldHighlight, - controller: chatController, - highlightColor: chatListConfig.repliedMessageConfig - ?.repliedMsgAutoScrollConfig.highlightColor ?? - Colors.grey, - highlightScale: chatListConfig.repliedMessageConfig - ?.repliedMsgAutoScrollConfig.highlightScale ?? - 1.1, - onMaxDuration: _onMaxDuration, - isFirstInGroup: widget.isFirstInGroup, - isLastInGroup: widget.isLastInGroup, - ), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SwipeToReply( + isMessageByCurrentUser: isMessageBySender, + onSwipe: isMessageBySender ? onLeftSwipe : onRightSwipe, + child: _messageView(), + ), + // slideOut indicator sits to the right of the bubble. + if (isMessageBySender && sendingAnimationType.isSlideOut) + getReceipt(), + // clockToTick renders inline inside text bubbles (see + // TextMessageView); for non-text messages there is no inline slot, + // so fall back to showing it beside the bubble. + if (isMessageBySender && + sendingAnimationType.isClockToTick && + !widget.message.messageType.isText) + getReceipt(), + ], ), + // textLabel indicator sits just below the bubble, aligned to its side. + if (isMessageBySender && sendingAnimationType.isTextLabel) + Padding( + padding: const EdgeInsets.only(right: 6), + child: getReceipt(), + ), ], ); } + Widget _messageView() { + return MessageView( + outgoingChatBubbleConfig: + chatListConfig.chatBubbleConfig?.outgoingChatBubbleConfig, + isLongPressEnable: (featureActiveConfig?.enableReactionPopup ?? true) || + (featureActiveConfig?.enableReplySnackBar ?? true), + inComingChatBubbleConfig: + chatListConfig.chatBubbleConfig?.inComingChatBubbleConfig, + message: widget.message, + isMessageBySender: isMessageBySender, + messageConfig: chatListConfig.messageConfig, + onLongPress: widget.onLongPress, + chatBubbleMaxWidth: chatListConfig.chatBubbleConfig?.maxWidth, + longPressAnimationDuration: + chatListConfig.chatBubbleConfig?.longPressAnimationDuration, + onDoubleTap: featureActiveConfig?.enableDoubleTapToLike ?? false + ? chatListConfig.chatBubbleConfig?.onDoubleTap ?? + (message) => currentUser != null + ? chatController?.setReaction( + emoji: heart, + messageId: message.id, + userId: currentUser!.id, + ) + : null + : null, + shouldHighlight: widget.shouldHighlight, + controller: chatController, + highlightColor: chatListConfig + .repliedMessageConfig?.repliedMsgAutoScrollConfig.highlightColor ?? + Colors.grey, + highlightScale: chatListConfig + .repliedMessageConfig?.repliedMsgAutoScrollConfig.highlightScale ?? + 1.1, + onMaxDuration: _onMaxDuration, + isFirstInGroup: widget.isFirstInGroup, + isLastInGroup: widget.isLastInGroup, + ); + } + void _onMaxDuration(int duration) => maxDuration = duration; } diff --git a/lib/src/widgets/chat_message_sending_to_sent_animation.dart b/lib/src/widgets/chat_message_sending_to_sent_animation.dart index bcdfeb45..5482729f 100644 --- a/lib/src/widgets/chat_message_sending_to_sent_animation.dart +++ b/lib/src/widgets/chat_message_sending_to_sent_animation.dart @@ -20,60 +20,241 @@ * SOFTWARE. */ -import 'dart:math'; - import 'package:chatview_utils/chatview_utils.dart'; import 'package:flutter/material.dart'; -class SendingMessageAnimatingWidget extends StatefulWidget { - const SendingMessageAnimatingWidget(this.status, {super.key}); +import '../utils/constants/constants.dart'; +import '../values/enumeration.dart'; + +/// Renders the sending → sent receipt indicator next to an outgoing chat +/// bubble, dispatching to one of the [SendingMessageAnimationType] +/// implementations. +class SendingMessageAnimatingWidget extends StatelessWidget { + const SendingMessageAnimatingWidget( + this.status, { + this.type = SendingMessageAnimationType.slideOut, + this.color, + super.key, + }); + + final MessageStatus status; + + /// The animation style used for the pending → sent transition. + final SendingMessageAnimationType type; + + /// Optional color for the indicator. When null, a default is used. Useful to + /// match the surrounding bubble (e.g. light color inside a colored bubble). + final Color? color; + + @override + Widget build(BuildContext context) { + return switch (type) { + SendingMessageAnimationType.slideOut => _SlideOutIndicator(status), + SendingMessageAnimationType.textLabel => _SendingTextLabel(status), + SendingMessageAnimationType.clockToTick => + _ClockToTickIndicator(status, color: color), + }; + } +} + +/// Default indicator: a small dot that slides out to the right once sent. +class _SlideOutIndicator extends StatefulWidget { + const _SlideOutIndicator(this.status); final MessageStatus status; @override - State createState() => - _SendingMessageAnimatingWidgetState(); + State<_SlideOutIndicator> createState() => _SlideOutIndicatorState(); } -class _SendingMessageAnimatingWidgetState - extends State with TickerProviderStateMixin { +class _SlideOutIndicatorState extends State<_SlideOutIndicator> + with SingleTickerProviderStateMixin { bool get isSent => widget.status != MessageStatus.pending; - bool isVisible = false; + late final AnimationController _ctrl; + late final Animation _dx; + late final Animation _width; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: sendingAnimationDuration, + // Start half-visible while pending, already collapsed when the message + // was sent before this widget was ever built (e.g. history scroll-in or + // ShowReceiptsIn.all) so no stuck dot remains. + value: isSent ? 0.0 : 1.0, + ); + final curved = CurvedAnimation(parent: _ctrl, curve: sendingAnimationCurve); + // value=1 → half visible (clip width = _half, circle at dx=0) + // value=0 → hidden (clip width = 0, circle slid out right by _half) + _dx = Tween(begin: sendingIndicatorSize / 2, end: 0.0) + .animate(curved); + _width = Tween(begin: 0.0, end: sendingIndicatorSize / 2) + .animate(curved); + } - void _attachOnStatusChangeListeners() { - if (isSent) { - Future.delayed(const Duration(milliseconds: 400), () { - isVisible = true; - if (mounted) { - setState(() {}); - } + @override + void didUpdateWidget(_SlideOutIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (isSent && oldWidget.status == MessageStatus.pending) { + Future.delayed(sendingAnimationDuration, () { + if (mounted) _ctrl.reverse(); }); } } @override Widget build(BuildContext context) { - _attachOnStatusChangeListeners(); - return AnimatedPadding( - curve: Curves.easeInOutExpo, - duration: const Duration(seconds: 1), - padding: EdgeInsets.only(right: isSent ? 5 : 8.0, bottom: isSent ? 8 : 2), - child: isVisible - ? const SizedBox() - : Transform.rotate( - angle: !isSent ? pi / 10 : -pi / 12, - child: const Padding( - padding: EdgeInsets.only( - left: 2, - bottom: 5, - ), - child: Icon( - Icons.send, - color: Colors.grey, - size: 12, - ), - )), + return Center( + child: ClipRect( + child: AnimatedBuilder( + animation: _ctrl, + builder: (context, child) => SizedBox( + width: _width.value, + height: sendingIndicatorSize, + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: sendingIndicatorSize, + maxWidth: sendingIndicatorSize, + child: Transform.translate( + offset: Offset(_dx.value, 0), + child: child, + ), + ), + ), + child: Container( + width: sendingIndicatorSize, + height: sendingIndicatorSize, + decoration: const BoxDecoration( + color: sendingIndicatorDefaultColor, + shape: BoxShape.circle, + ), + ), + ), + ), + ); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } +} + +/// Minimal "Sending..." text label that fades and collapses away once sent. +class _SendingTextLabel extends StatefulWidget { + const _SendingTextLabel(this.status); + + final MessageStatus status; + + @override + State<_SendingTextLabel> createState() => _SendingTextLabelState(); +} + +class _SendingTextLabelState extends State<_SendingTextLabel> + with SingleTickerProviderStateMixin { + bool get isSent => widget.status != MessageStatus.pending; + + late final AnimationController _ctrl; + late final Animation _animation; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController( + vsync: this, + duration: sendingAnimationDuration, + // Start fully visible when pending, collapsed when already sent. + value: isSent ? 0.0 : 1.0, + ); + _animation = CurvedAnimation(parent: _ctrl, curve: sendingAnimationCurve); + } + + @override + void didUpdateWidget(_SendingTextLabel oldWidget) { + super.didUpdateWidget(oldWidget); + if (isSent && oldWidget.status == MessageStatus.pending) { + _ctrl.reverse(); + } + } + + @override + Widget build(BuildContext context) { + // Collapse vertically (so no empty space is left below the bubble once + // sent) while keeping the label pinned to the sender side (right). + return AnimatedBuilder( + animation: _animation, + builder: (context, child) => ClipRect( + child: Align( + alignment: Alignment.centerRight, + heightFactor: _animation.value.clamp(0.0, 1.0), + child: Opacity( + opacity: _animation.value.clamp(0.0, 1.0), + child: child, + ), + ), + ), + child: const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: Text( + sendingLabelText, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.clip, + style: TextStyle( + fontSize: 11, + color: sendingIndicatorDefaultColor, + ), + ), + ), + ); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } +} + +/// WhatsApp-style indicator that cross-fades between three states: +/// * [MessageStatus.pending] → a clock icon. +/// * sent (delivered / undelivered) → a single tick. +/// * [MessageStatus.read] → a double tick. +class _ClockToTickIndicator extends StatelessWidget { + const _ClockToTickIndicator(this.status, {this.color}); + + final MessageStatus status; + final Color? color; + + IconData get _icon => switch (status) { + MessageStatus.pending => Icons.access_time, + MessageStatus.read => Icons.done_all, + _ => Icons.check, + }; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: AnimatedSwitcher( + duration: sendingAnimationDuration, + switchInCurve: sendingAnimationCurve, + switchOutCurve: sendingAnimationCurve, + transitionBuilder: (child, animation) => ScaleTransition( + scale: animation, + child: FadeTransition(opacity: animation, child: child), + ), + child: Icon( + _icon, + key: ValueKey(_icon), + size: sendingTickIconSize, + color: color ?? sendingIndicatorDefaultColor, + ), + ), ); } } diff --git a/lib/src/widgets/text_message_view.dart b/lib/src/widgets/text_message_view.dart index 38d42507..867e942c 100644 --- a/lib/src/widgets/text_message_view.dart +++ b/lib/src/widgets/text_message_view.dart @@ -25,6 +25,7 @@ import 'package:flutter/material.dart'; import '../extensions/extensions.dart'; import '../utils/constants/constants.dart'; +import 'chat_message_sending_to_sent_animation.dart'; import 'link_preview.dart'; import 'reaction_widget.dart'; @@ -121,6 +122,16 @@ class TextMessageView extends StatelessWidget { child: baseWidget, ) : baseWidget; + + // WhatsApp-style clock/tick rendered inline at the bubble's bottom-right + // (next to the timestamp) for outgoing messages. Honors the + // receiptsBuilderVisibility feature flag, consistent with the beside-bubble + // receipt path in ChatBubbleWidget.getReceipt(). + final showInBubbleReceipt = isMessageBySender && + (featureActiveConfig?.receiptsBuilderVisibility ?? true) && + (outgoingChatBubbleConfig + ?.receiptsWidgetConfig?.sendingAnimationType.isClockToTick ?? + false); return Stack( clipBehavior: Clip.none, children: [ @@ -144,8 +155,40 @@ class TextMessageView extends StatelessWidget { ? outgoingChatBubbleConfig?.boxShadow : inComingChatBubbleConfig?.boxShadow, ), - child: showTimeInChatBubble - ? LayoutBuilder( + child: showInBubbleReceipt + ? Stack( + alignment: Alignment.bottomRight, + children: [ + Padding( + padding: const EdgeInsets.only( + bottom: sendingReceiptInBubbleReservedHeight), + child: messageWidget, + ), + Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (showTimeInChatBubble) ...[ + Text(timeText, style: timestampStyle), + const SizedBox(width: 4), + ], + ValueListenableBuilder( + valueListenable: message.statusNotifier, + builder: (_, status, __) => + SendingMessageAnimatingWidget( + status, + type: SendingMessageAnimationType.clockToTick, + color: outgoingChatBubbleConfig + ?.receiptsWidgetConfig?.sendingIndicatorColor ?? + timestampStyle.color, + ), + ), + ], + ), + ], + ) + : showTimeInChatBubble + ? LayoutBuilder( builder: (context, constraints) { if (extractedUrls.isNotEmpty) { return Column(