From 02d0120b3a1645e636717e1525a568c81ef3f6cc Mon Sep 17 00:00:00 2001 From: Prabhanu Gunaweera Date: Sat, 6 Jun 2026 08:07:27 +0530 Subject: [PATCH] feat: add widget tests and GitHub Actions CI - Add 47 tests covering every public widget: smoke tests, isSender alignment, callback firing (onTap, onSend, onSwipe, onReply, onPlay, onAddReaction, onReactionSelected), MessageBarStyle defaults and propagation, BubbleNormalAudio waveform branch, MessageGroupHelper grouping logic, and Algo.dateChipText formatting - Add .github/workflows/ci.yml running on every push/PR to master and develop: flutter pub get, dart format check, dart analyze with fatal infos, flutter test, and a publish dry-run - Add CI status badge to README - Reformat lib/ to match dart format defaults (clears CI format check) Co-Authored-By: Claude Opus 4.7 --- .github/workflows/ci.yml | 41 +++++ README.md | 1 + lib/algo/date_chip_text.dart | 3 +- lib/bubbles/bubble_link_preview.dart | 87 +++++----- lib/bubbles/bubble_normal.dart | 20 +++ lib/bubbles/bubble_normal_audio.dart | 34 +++- lib/bubbles/bubble_normal_image.dart | 152 +++++++++-------- lib/bubbles/bubble_reply.dart | 57 ++++--- lib/bubbles/bubble_special_one.dart | 12 ++ lib/bubbles/bubble_special_three.dart | 12 ++ lib/bubbles/bubble_special_two.dart | 12 ++ lib/date_chips/date_chip.dart | 1 + lib/groups/bubble_group_builder.dart | 3 +- lib/indicators/typing_indicator.dart | 28 ++-- lib/message_bars/message_bar.dart | 183 +++++++++++---------- lib/reactions/bubble_reaction.dart | 46 +++--- lib/swipe/swipeable_bubble.dart | 3 +- lib/utils/bubble_status_row.dart | 3 +- test/algo/date_chip_text_test.dart | 21 +++ test/bubbles/bubble_link_preview_test.dart | 24 +++ test/bubbles/bubble_normal_audio_test.dart | 78 +++++++++ test/bubbles/bubble_normal_image_test.dart | 48 ++++++ test/bubbles/bubble_normal_test.dart | 70 ++++++++ test/bubbles/bubble_reply_test.dart | 42 +++++ test/bubbles/bubble_special_test.dart | 27 +++ test/chatbubbles_test.dart | 1 - test/date_chips/date_chip_test.dart | 20 +++ test/groups/bubble_group_builder_test.dart | 49 ++++++ test/groups/message_group_helper_test.dart | 74 +++++++++ test/indicators/typing_indicator_test.dart | 34 ++++ test/message_bars/message_bar_test.dart | 87 ++++++++++ test/reactions/bubble_reaction_test.dart | 75 +++++++++ test/swipe/swipeable_bubble_test.dart | 58 +++++++ test/test_utils.dart | 6 + 34 files changed, 1139 insertions(+), 273 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 test/algo/date_chip_text_test.dart create mode 100644 test/bubbles/bubble_link_preview_test.dart create mode 100644 test/bubbles/bubble_normal_audio_test.dart create mode 100644 test/bubbles/bubble_normal_image_test.dart create mode 100644 test/bubbles/bubble_normal_test.dart create mode 100644 test/bubbles/bubble_reply_test.dart create mode 100644 test/bubbles/bubble_special_test.dart delete mode 100644 test/chatbubbles_test.dart create mode 100644 test/date_chips/date_chip_test.dart create mode 100644 test/groups/bubble_group_builder_test.dart create mode 100644 test/groups/message_group_helper_test.dart create mode 100644 test/indicators/typing_indicator_test.dart create mode 100644 test/message_bars/message_bar_test.dart create mode 100644 test/reactions/bubble_reaction_test.dart create mode 100644 test/swipe/swipeable_bubble_test.dart create mode 100644 test/test_utils.dart diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..939fed9 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,41 @@ +name: CI + +on: + push: + branches: [master, develop] + pull_request: + branches: [master, develop] + +jobs: + test: + name: Analyze, format, test + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: '3.27.0' + cache: true + + - name: Print Flutter version + run: flutter --version + + - name: Install dependencies + run: flutter pub get + + - name: Verify formatting + run: dart format --output=none --set-exit-if-changed lib/ test/ + + - name: Static analysis + run: dart analyze --fatal-infos --fatal-warnings lib/ test/ + + - name: Run tests + run: flutter test + + - name: Dry-run publish (catches packaging issues) + run: flutter pub publish --dry-run diff --git a/README.md b/README.md index 0326f76..dae3bf8 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,7 @@ # chat_bubbles plugin ![Pub Version](https://img.shields.io/pub/v/chat_bubbles?color=blue) +[![CI](https://github.com/prahack/chat_bubbles/actions/workflows/ci.yml/badge.svg)](https://github.com/prahack/chat_bubbles/actions/workflows/ci.yml) ![GitHub](https://img.shields.io/github/license/prahack/chat_bubbles) ![GitHub forks](https://img.shields.io/github/forks/prahack/chat_bubbles) ![GitHub Repo stars](https://img.shields.io/github/stars/prahack/chat_bubbles) diff --git a/lib/algo/date_chip_text.dart b/lib/algo/date_chip_text.dart index 8eab9ed..4f79e35 100644 --- a/lib/algo/date_chip_text.dart +++ b/lib/algo/date_chip_text.dart @@ -20,8 +20,7 @@ class DateChipText { final now = DateTime.now(); if (_formatter.format(now) == _formatter.format(date)) { return 'Today'; - } else if (_formatter - .format(DateTime(now.year, now.month, now.day - 1)) == + } else if (_formatter.format(DateTime(now.year, now.month, now.day - 1)) == _formatter.format(date)) { return 'Yesterday'; } else { diff --git a/lib/bubbles/bubble_link_preview.dart b/lib/bubbles/bubble_link_preview.dart index 74436fd..1188051 100644 --- a/lib/bubbles/bubble_link_preview.dart +++ b/lib/bubbles/bubble_link_preview.dart @@ -36,82 +36,82 @@ const double defaultBubbleRadiusLinkPreview = 16; class BubbleLinkPreview extends StatelessWidget { /// the URL being previewed final String url; - + /// the title of the link preview final String? title; - + /// the description of the link preview final String? description; - + /// the preview image URL final String? imageUrl; - + /// optional message text accompanying the link final String? text; - + /// chat bubble [BorderRadius] final double bubbleRadius; - + /// message sender final bool isSender; - + /// chat bubble color final Color color; - + /// link preview card background color final Color? previewBackgroundColor; - + /// chat bubble tail final bool tail; - + /// message state - whether the message has been sent final bool sent; - + /// message state - whether the message has been delivered final bool delivered; - + /// message state - whether the message has been seen final bool seen; - + /// text style for the message text final TextStyle textStyle; - + /// text style for the link preview title final TextStyle? titleTextStyle; - + /// text style for the link preview description final TextStyle? descriptionTextStyle; - + /// text style for the URL final TextStyle? urlTextStyle; - + /// constraints for the chat bubble final BoxConstraints? constraints; - + /// widget displayed before the bubble for non-senders final Widget? leading; - + /// widget displayed after the bubble for senders final Widget? trailing; - + /// outer margin of the bubble final EdgeInsets margin; - + /// inner padding of the bubble final EdgeInsets padding; - + /// callback function when the bubble is tapped final VoidCallback? onTap; - + /// callback function when the bubble is long pressed final VoidCallback? onLongPress; - + /// callback function when the link preview is tapped final VoidCallback? onLinkTap; - + /// height of the preview image final double? imageHeight; - + /// whether to show the preview image final bool showImage; @@ -170,7 +170,7 @@ class BubbleLinkPreview extends StatelessWidget { Widget build(BuildContext context) { bool stateTick = false; Icon? stateIcon; - + if (sent) { stateTick = true; stateIcon = Icon( @@ -279,12 +279,14 @@ class BubbleLinkPreview extends StatelessWidget { color: previewBackgroundColor ?? Colors.grey.withValues(alpha: 0.1), borderRadius: BorderRadius.only( - topLeft: (text == null || text!.isEmpty) && !isForwarded - ? Radius.circular(bubbleRadius) - : Radius.zero, - topRight: (text == null || text!.isEmpty) && !isForwarded - ? Radius.circular(bubbleRadius) - : Radius.zero, + topLeft: + (text == null || text!.isEmpty) && !isForwarded + ? Radius.circular(bubbleRadius) + : Radius.zero, + topRight: + (text == null || text!.isEmpty) && !isForwarded + ? Radius.circular(bubbleRadius) + : Radius.zero, bottomLeft: Radius.circular(tail ? isSender ? bubbleRadius @@ -301,13 +303,17 @@ class BubbleLinkPreview extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // Preview image - if (showImage && imageUrl != null && imageUrl!.isNotEmpty) + if (showImage && + imageUrl != null && + imageUrl!.isNotEmpty) ClipRRect( borderRadius: BorderRadius.only( - topLeft: (text == null || text!.isEmpty) && !isForwarded + topLeft: (text == null || text!.isEmpty) && + !isForwarded ? Radius.circular(bubbleRadius) : Radius.zero, - topRight: (text == null || text!.isEmpty) && !isForwarded + topRight: (text == null || text!.isEmpty) && + !isForwarded ? Radius.circular(bubbleRadius) : Radius.zero, ), @@ -344,11 +350,13 @@ class BubbleLinkPreview extends StatelessWidget { overflow: TextOverflow.ellipsis, ), // Description - if (description != null && description!.isNotEmpty) ...[ + if (description != null && + description!.isNotEmpty) ...[ SizedBox(height: 4), Text( description!, - style: descriptionTextStyle ?? defaultDescriptionStyle, + style: descriptionTextStyle ?? + defaultDescriptionStyle, maxLines: 3, overflow: TextOverflow.ellipsis, ), @@ -383,7 +391,8 @@ class BubbleLinkPreview extends StatelessWidget { // Message status row if (showStatusArea) Padding( - padding: const EdgeInsets.only(right: 8, bottom: 6, top: 4), + padding: + const EdgeInsets.only(right: 8, bottom: 6, top: 4), child: Align( alignment: Alignment.centerRight, child: BubbleStatusRow( diff --git a/lib/bubbles/bubble_normal.dart b/lib/bubbles/bubble_normal.dart index 0ff6722..48bbcec 100644 --- a/lib/bubbles/bubble_normal.dart +++ b/lib/bubbles/bubble_normal.dart @@ -50,44 +50,64 @@ const double defaultBubbleRadius = 16; class BubbleNormal extends StatelessWidget { /// chat bubble [BorderRadius] final double bubbleRadius; + /// message sender final bool isSender; + /// chat bubble color final Color color; + /// message text final String text; + /// chat bubble tail final bool tail; + /// message state - whether the message has been sent final bool sent; + /// message state - whether the message has been delivered final bool delivered; + /// message state - whether the message has been seen final bool seen; + /// text style for the message final TextStyle textStyle; + /// constraints for the chat bubble final BoxConstraints? constraints; + /// widget displayed before the bubble for non-senders final Widget? leading; + /// widget displayed after the bubble for senders final Widget? trailing; + /// outer margin of the bubble final EdgeInsets margin; + /// inner padding of the bubble final EdgeInsets padding; + /// callback function when the bubble is tapped final VoidCallback? onTap; + /// callback function when the bubble is double tapped final VoidCallback? onDoubleTap; + /// callback function when the bubble is long pressed final VoidCallback? onLongPress; + /// optional timestamp string shown at the bottom-right (e.g. "12:34 PM") final String? timestamp; + /// shows an "Edited" label next to the status area when true final bool isEdited; + /// shows a "Forwarded" banner at the top of the bubble when true final bool isForwarded; + /// optional identifier for tracking the message final String? messageId; diff --git a/lib/bubbles/bubble_normal_audio.dart b/lib/bubbles/bubble_normal_audio.dart index bb54a4a..668289e 100644 --- a/lib/bubbles/bubble_normal_audio.dart +++ b/lib/bubbles/bubble_normal_audio.dart @@ -32,8 +32,8 @@ class _WaveformPainter extends CustomPainter { final int activeCount = (data.length * progress).round(); for (int i = 0; i < data.length; i++) { - final double normalizedHeight = - minBarHeightRatio + (1.0 - minBarHeightRatio) * data[i].clamp(0.0, 1.0); + final double normalizedHeight = minBarHeightRatio + + (1.0 - minBarHeightRatio) * data[i].clamp(0.0, 1.0); final double barHeight = size.height * normalizedHeight; final double x = i * (barWidth + gap); final double top = (size.height - barHeight) / 2; @@ -111,42 +111,61 @@ class _WaveformPainter extends CustomPainter { class BubbleNormalAudio extends StatelessWidget { /// [onSeekChanged] double pass function to take actions on seek changes final void Function(double value) onSeekChanged; + /// [onPlayPauseButtonClick] void function to handle play pause button click final void Function() onPlayPauseButtonClick; + /// [isPlaying],[isPause] parameters to handle playing state final bool isPlaying; + /// [isPlaying],[isPause] parameters to handle playing state final bool isPause; + ///[duration] is the duration of the audio message in seconds final double? duration; + ///[position] is the current position of the audio message playing in seconds final double? position; + /// Whether the audio is currently loading final bool isLoading; + ///chat bubble [BorderRadius] can be customized using [bubbleRadius] final double bubbleRadius; + /// Determines if the message is from the sender ([true]) or receiver ([false]) final bool isSender; + /// The background color of the chat bubble final Color color; + /// Whether to show the tail of the chat bubble final bool tail; + /// Whether the message has been sent (shows one tick) final bool sent; + /// Whether the message has been delivered (shows two ticks) final bool delivered; + /// Whether the message has been seen (shows two blue ticks) final bool seen; + /// Custom text style for the duration and position text final TextStyle textStyle; + /// Constraints for the chat bubble final BoxConstraints? constraints; + /// optional timestamp string shown at the bottom-right (e.g. "12:34 PM") final String? timestamp; + /// shows an "Edited" label next to the status area when true final bool isEdited; + /// shows a "Forwarded" banner at the top of the bubble when true final bool isForwarded; + /// optional identifier for tracking the message final String? messageId; @@ -292,12 +311,11 @@ class BubbleNormalAudio extends StatelessWidget { GestureDetector( onTap: onPlaybackSpeedChanged != null ? () { - final double next = - playbackSpeed >= 2.0 - ? 1.0 - : playbackSpeed >= 1.5 - ? 2.0 - : 1.5; + final double next = playbackSpeed >= 2.0 + ? 1.0 + : playbackSpeed >= 1.5 + ? 2.0 + : 1.5; onPlaybackSpeedChanged!(next); } : null, diff --git a/lib/bubbles/bubble_normal_image.dart b/lib/bubbles/bubble_normal_image.dart index 6b99294..0f92081 100644 --- a/lib/bubbles/bubble_normal_image.dart +++ b/lib/bubbles/bubble_normal_image.dart @@ -51,38 +51,55 @@ class BubbleNormalImage extends StatelessWidget { /// widget id for Hero animation final String id; + /// image widget final Widget image; + /// chat bubble [BorderRadius] final double bubbleRadius; + /// message sender final bool isSender; + /// chat bubble color final Color color; + /// chat bubble tail final bool tail; + /// message state - whether the message has been sent final bool sent; + /// message state - whether the message has been delivered final bool delivered; + /// message state - whether the message has been seen final bool seen; + /// callback function when the bubble is tapped final VoidCallback? onTap; + /// callback function when the bubble is long pressed final VoidCallback? onLongPress; + /// widget displayed before the bubble for non-senders final Widget? leading; + /// widget displayed after the bubble for senders final Widget? trailing; + /// outer margin of the bubble final EdgeInsets? margin; + /// inner padding of the bubble final EdgeInsets? padding; + /// optional timestamp string overlaid at the bottom-right of the image final String? timestamp; + /// shows a "Forwarded" banner overlaid at the top of the image when true final bool isForwarded; + /// optional identifier for tracking the message final String? messageId; @@ -158,83 +175,82 @@ class BubbleNormalImage extends StatelessWidget { maxHeight: MediaQuery.of(context).size.width * .5, ), child: GestureDetector( - onLongPress: onLongPress, - onTap: onTap ?? - () { - Navigator.push(context, MaterialPageRoute(builder: (_) { - return _DetailScreen( - tag: id, - image: image, - ); - })); - }, - child: Hero( - tag: id, - child: Stack( - children: [ - Container( - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.only( - topLeft: Radius.circular(bubbleRadius), - topRight: Radius.circular(bubbleRadius), - bottomLeft: Radius.circular(tail - ? isSender - ? bubbleRadius - : 0 - : defaultBubbleRadiusImage), - bottomRight: Radius.circular(tail - ? isSender - ? 0 - : bubbleRadius - : defaultBubbleRadiusImage), - ), + onLongPress: onLongPress, + onTap: onTap ?? + () { + Navigator.push(context, MaterialPageRoute(builder: (_) { + return _DetailScreen( + tag: id, + image: image, + ); + })); + }, + child: Hero( + tag: id, + child: Stack( + children: [ + Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(bubbleRadius), + topRight: Radius.circular(bubbleRadius), + bottomLeft: Radius.circular(tail + ? isSender + ? bubbleRadius + : 0 + : defaultBubbleRadiusImage), + bottomRight: Radius.circular(tail + ? isSender + ? 0 + : bubbleRadius + : defaultBubbleRadiusImage), ), - child: Padding( - padding: const EdgeInsets.all(4.0), - child: ClipRRect( - borderRadius: BorderRadius.circular(bubbleRadius), - child: image, - ), + ), + child: Padding( + padding: const EdgeInsets.all(4.0), + child: ClipRRect( + borderRadius: BorderRadius.circular(bubbleRadius), + child: image, ), ), - if (isForwarded) - Positioned( - top: 8, - left: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(8), - ), - child: const BubbleForwardedHeader( - color: Colors.white), + ), + if (isForwarded) + Positioned( + top: 8, + left: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), ), + child: const BubbleForwardedHeader(color: Colors.white), ), - if (showStatusArea) - Positioned( - bottom: 8, - right: 8, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, vertical: 2), - decoration: BoxDecoration( - color: Colors.black.withValues(alpha: 0.45), - borderRadius: BorderRadius.circular(8), - ), - child: BubbleStatusRow( - stateIcon: stateTick ? stateIcon : null, - timestamp: timestamp, - textColor: Colors.white, - ), + ), + if (showStatusArea) + Positioned( + bottom: 8, + right: 8, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 4, vertical: 2), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.45), + borderRadius: BorderRadius.circular(8), + ), + child: BubbleStatusRow( + stateIcon: stateTick ? stateIcon : null, + timestamp: timestamp, + textColor: Colors.white, ), ), - ], - ), + ), + ], ), ), + ), ), if (isSender && trailing != null) SizedBox.shrink(), ], diff --git a/lib/bubbles/bubble_reply.dart b/lib/bubbles/bubble_reply.dart index 1a14069..78a3fa0 100644 --- a/lib/bubbles/bubble_reply.dart +++ b/lib/bubbles/bubble_reply.dart @@ -38,70 +38,70 @@ const double defaultBubbleRadiusReply = 16; class BubbleReply extends StatelessWidget { /// the text of the message being replied to final String repliedMessage; - + /// the name/identifier of the sender of the replied message final String repliedMessageSender; - + /// the current message text final String text; - + /// chat bubble [BorderRadius] final double bubbleRadius; - + /// message sender final bool isSender; - + /// chat bubble color final Color color; - + /// reply indicator line color final Color replyBorderColor; - + /// background color of the reply section final Color? replyBackgroundColor; - + /// chat bubble tail final bool tail; - + /// message state - whether the message has been sent final bool sent; - + /// message state - whether the message has been delivered final bool delivered; - + /// message state - whether the message has been seen final bool seen; - + /// text style for the current message final TextStyle textStyle; - + /// text style for the replied message final TextStyle? repliedMessageTextStyle; - + /// text style for the replied message sender name final TextStyle? repliedMessageSenderTextStyle; - + /// constraints for the chat bubble final BoxConstraints? constraints; - + /// widget displayed before the bubble for non-senders final Widget? leading; - + /// widget displayed after the bubble for senders final Widget? trailing; - + /// outer margin of the bubble final EdgeInsets margin; - + /// inner padding of the bubble final EdgeInsets padding; - + /// callback function when the bubble is tapped final VoidCallback? onTap; - + /// callback function when the bubble is long pressed final VoidCallback? onLongPress; - + /// callback function when the reply section is tapped final VoidCallback? onReplyTap; @@ -156,7 +156,7 @@ class BubbleReply extends StatelessWidget { Widget build(BuildContext context) { bool stateTick = false; Icon? stateIcon; - + if (sent) { stateTick = true; stateIcon = Icon( @@ -239,8 +239,7 @@ class BubbleReply extends StatelessWidget { // Forwarded banner (shown above the reply section) if (isForwarded) Padding( - padding: - const EdgeInsets.fromLTRB(12, 8, 12, 0), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 0), child: BubbleForwardedHeader(color: forwardedColor), ), // Reply section @@ -253,10 +252,10 @@ class BubbleReply extends StatelessWidget { ? Colors.black.withValues(alpha: 0.1) : Colors.grey.withValues(alpha: 0.1)), borderRadius: BorderRadius.only( - topLeft: Radius.circular( - isForwarded ? 0 : bubbleRadius), - topRight: Radius.circular( - isForwarded ? 0 : bubbleRadius), + topLeft: + Radius.circular(isForwarded ? 0 : bubbleRadius), + topRight: + Radius.circular(isForwarded ? 0 : bubbleRadius), ), ), padding: EdgeInsets.all(8), diff --git a/lib/bubbles/bubble_special_one.dart b/lib/bubbles/bubble_special_one.dart index 9b17813..ca1b507 100644 --- a/lib/bubbles/bubble_special_one.dart +++ b/lib/bubbles/bubble_special_one.dart @@ -22,28 +22,40 @@ import '../utils/bubble_status_row.dart'; class BubbleSpecialOne extends StatelessWidget { /// message sender final bool isSender; + /// message text final String text; + /// chat bubble tail final bool tail; + /// chat bubble color final Color color; + /// message state - whether the message has been sent final bool sent; + /// message state - whether the message has been delivered final bool delivered; + /// message state - whether the message has been seen final bool seen; + /// text style for the message final TextStyle textStyle; + /// constraints for the chat bubble final BoxConstraints? constraints; + /// optional timestamp string shown at the bottom-right (e.g. "12:34 PM") final String? timestamp; + /// shows an "Edited" label next to the status area when true final bool isEdited; + /// shows a "Forwarded" banner at the top of the bubble when true final bool isForwarded; + /// optional identifier for tracking the message final String? messageId; diff --git a/lib/bubbles/bubble_special_three.dart b/lib/bubbles/bubble_special_three.dart index ecc4449..dd4750e 100644 --- a/lib/bubbles/bubble_special_three.dart +++ b/lib/bubbles/bubble_special_three.dart @@ -22,28 +22,40 @@ import '../utils/bubble_status_row.dart'; class BubbleSpecialThree extends StatelessWidget { /// message sender final bool isSender; + /// message text final String text; + /// chat bubble tail final bool tail; + /// chat bubble color final Color color; + /// message state - whether the message has been sent final bool sent; + /// message state - whether the message has been delivered final bool delivered; + /// message state - whether the message has been seen final bool seen; + /// text style for the message final TextStyle textStyle; + /// constraints for the chat bubble final BoxConstraints? constraints; + /// optional timestamp string shown at the bottom-right (e.g. "12:34 PM") final String? timestamp; + /// shows an "Edited" label next to the status area when true final bool isEdited; + /// shows a "Forwarded" banner at the top of the bubble when true final bool isForwarded; + /// optional identifier for tracking the message final String? messageId; diff --git a/lib/bubbles/bubble_special_two.dart b/lib/bubbles/bubble_special_two.dart index 33cd8c8..8c2e244 100644 --- a/lib/bubbles/bubble_special_two.dart +++ b/lib/bubbles/bubble_special_two.dart @@ -22,28 +22,40 @@ import '../utils/bubble_status_row.dart'; class BubbleSpecialTwo extends StatelessWidget { /// message sender final bool isSender; + /// message text final String text; + /// chat bubble tail final bool tail; + /// chat bubble color final Color color; + /// message state - whether the message has been sent final bool sent; + /// message state - whether the message has been delivered final bool delivered; + /// message state - whether the message has been seen final bool seen; + /// text style for the message final TextStyle textStyle; + /// constraints for the chat bubble final BoxConstraints? constraints; + /// optional timestamp string shown at the bottom-right (e.g. "12:34 PM") final String? timestamp; + /// shows an "Edited" label next to the status area when true final bool isEdited; + /// shows a "Forwarded" banner at the top of the bubble when true final bool isForwarded; + /// optional identifier for tracking the message final String? messageId; diff --git a/lib/date_chips/date_chip.dart b/lib/date_chips/date_chip.dart index 865759c..8b36153 100644 --- a/lib/date_chips/date_chip.dart +++ b/lib/date_chips/date_chip.dart @@ -9,6 +9,7 @@ import 'package:flutter/material.dart'; class DateChip extends StatelessWidget { /// the date to display on the chip final DateTime date; + /// the background color of the chip final Color color; diff --git a/lib/groups/bubble_group_builder.dart b/lib/groups/bubble_group_builder.dart index 90555ae..c1fce2b 100644 --- a/lib/groups/bubble_group_builder.dart +++ b/lib/groups/bubble_group_builder.dart @@ -76,8 +76,7 @@ class BubbleGroupBuilder extends StatelessWidget { Widget build(BuildContext context) { if (itemCount == 0) return const SizedBox.shrink(); - final List ids = - List.generate(itemCount, (i) => senderIdOf(i)); + final List ids = List.generate(itemCount, (i) => senderIdOf(i)); final List? timestamps = timestampOf != null ? List.generate(itemCount, (i) => timestampOf!(i) ?? DateTime(0)) diff --git a/lib/indicators/typing_indicator.dart b/lib/indicators/typing_indicator.dart index 5c8e569..1750b1c 100644 --- a/lib/indicators/typing_indicator.dart +++ b/lib/indicators/typing_indicator.dart @@ -17,28 +17,28 @@ import 'package:flutter/material.dart'; class TypingIndicator extends StatefulWidget { /// background color of the typing bubble final Color bubbleColor; - + /// color of the animated dots final Color dotColor; - + /// whether to show the typing indicator final bool showIndicator; - + /// duration of the animation cycle final Duration animationDuration; - + /// number of dots to display final int numberOfDots; - + /// size of each dot final double dotSize; - + /// spacing between dots final double dotSpacing; - + /// padding inside the bubble final EdgeInsets padding; - + /// border radius of the bubble final double borderRadius; @@ -200,22 +200,22 @@ class _AnimatedDot extends StatelessWidget { class TypingIndicatorWave extends StatefulWidget { /// background color of the typing bubble final Color bubbleColor; - + /// color of the animated dots final Color dotColor; - + /// whether to show the typing indicator final bool showIndicator; - + /// duration of the animation cycle final Duration animationDuration; - + /// size of each dot final double dotSize; - + /// padding inside the bubble final EdgeInsets padding; - + /// border radius of the bubble final double borderRadius; diff --git a/lib/message_bars/message_bar.dart b/lib/message_bars/message_bar.dart index c8aec46..4bf5734 100644 --- a/lib/message_bars/message_bar.dart +++ b/lib/message_bars/message_bar.dart @@ -32,43 +32,59 @@ import 'package:flutter/material.dart'; /// [onSend] is the send button action /// [onTapCloseReply] is the close button action of the close button on the /// reply widget; usually change [replying] attribute to false -/// +/// /// # CLASSES /// [messageBarStyle] contains styling information for the textfield class MessageBar extends StatelessWidget { /// whether the message bar is in reply mode final bool replying; + /// the name or text of the message being replied to final String replyingTo; + /// additional action buttons like camera and file select final List actions; + /// text controller for the message input field final TextEditingController _textController = TextEditingController(); + /// background color of the reply widget final Color replyWidgetColor; + /// color of the reply icon final Color replyIconColor; + /// color of the close icon in reply widget final Color replyCloseColor; + /// background color of the message bar final Color messageBarColor; + /// hint text for the message input field final String messageBarHintText; + /// text style for the hint text final TextStyle messageBarHintStyle; + /// text style for the input text final TextStyle textFieldTextStyle; + /// color of the send button final Color sendButtonColor; + /// callback function triggered on text change final void Function(String)? onTextChanged; + /// callback function triggered when send button is pressed final void Function(String)? onSend; + /// callback function triggered when close reply button is pressed final void Function()? onTapCloseReply; + /// config to control appearance of the MessageBar textfield final MessageBarStyle messageBarStyle; + /// optional custom widget to use as a send button final Widget? sendButton; @@ -104,95 +120,96 @@ class MessageBar extends StatelessWidget { child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ - replying - ? Container( - color: replyWidgetColor, - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: Row( - children: [ - Icon( - Icons.reply, - color: replyIconColor, - size: 24, - ), - Expanded( - child: Text( - 'Re : $replyingTo', - overflow: TextOverflow.ellipsis, - ), - ), - InkWell( - onTap: onTapCloseReply, - child: Icon( - Icons.close, - color: replyCloseColor, - size: 24, - ), + replying + ? Container( + color: replyWidgetColor, + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Row( + children: [ + Icon( + Icons.reply, + color: replyIconColor, + size: 24, + ), + Expanded( + child: Text( + 'Re : $replyingTo', + overflow: TextOverflow.ellipsis, ), - ], - )) - : Container(), - replying - ? Container( - height: 1, - color: Colors.grey.shade300, - ) - : Container(), - Container( - color: messageBarColor, - padding: const EdgeInsets.symmetric( - vertical: 8, - horizontal: 16, - ), - child: Row( - children: [ - ...actions, - Expanded( - child: TextField( - controller: _textController, - keyboardType: messageBarStyle.keyboardType, - textCapitalization: messageBarStyle.textCapitalization, - minLines: messageBarStyle.minLines, - maxLines: messageBarStyle.maxLines, - onChanged: onTextChanged, - style: textFieldTextStyle, - decoration: InputDecoration( - hintText: messageBarHintText, - hintMaxLines: 1, - contentPadding: messageBarStyle.contentPadding, - hintStyle: messageBarHintStyle, - fillColor: messageBarStyle.fillColor, - filled: true, - enabledBorder: messageBarStyle.enabledBorder, - focusedBorder: messageBarStyle.focusedBorder, + ), + InkWell( + onTap: onTapCloseReply, + child: Icon( + Icons.close, + color: replyCloseColor, + size: 24, ), + ), + ], + )) + : Container(), + replying + ? Container( + height: 1, + color: Colors.grey.shade300, + ) + : Container(), + Container( + color: messageBarColor, + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + child: Row( + children: [ + ...actions, + Expanded( + child: TextField( + controller: _textController, + keyboardType: messageBarStyle.keyboardType, + textCapitalization: messageBarStyle.textCapitalization, + minLines: messageBarStyle.minLines, + maxLines: messageBarStyle.maxLines, + onChanged: onTextChanged, + style: textFieldTextStyle, + decoration: InputDecoration( + hintText: messageBarHintText, + hintMaxLines: 1, + contentPadding: messageBarStyle.contentPadding, + hintStyle: messageBarHintStyle, + fillColor: messageBarStyle.fillColor, + filled: true, + enabledBorder: messageBarStyle.enabledBorder, + focusedBorder: messageBarStyle.focusedBorder, ), ), - Padding( - padding: const EdgeInsets.only(left: 16), - child: InkWell( - child: sendButton ?? Icon( - Icons.send, - color: sendButtonColor, - size: 24, - ), - onTap: () { - if (_textController.text.trim() != '') { - if (onSend != null) { - onSend!(_textController.text.trim()); - } - _textController.text = ''; + ), + Padding( + padding: const EdgeInsets.only(left: 16), + child: InkWell( + child: sendButton ?? + Icon( + Icons.send, + color: sendButtonColor, + size: 24, + ), + onTap: () { + if (_textController.text.trim() != '') { + if (onSend != null) { + onSend!(_textController.text.trim()); } - }, - ), + _textController.text = ''; + } + }, ), - ], - ), + ), + ], ), - ], + ), + ], ), ); } diff --git a/lib/reactions/bubble_reaction.dart b/lib/reactions/bubble_reaction.dart index 53f7c4e..e62f5e8 100644 --- a/lib/reactions/bubble_reaction.dart +++ b/lib/reactions/bubble_reaction.dart @@ -4,10 +4,10 @@ import 'package:flutter/material.dart'; class Reaction { /// the emoji or reaction icon final String emoji; - + /// count of users who reacted with this emoji final int count; - + /// whether the current user has reacted with this emoji final bool isUserReacted; @@ -32,40 +32,40 @@ class Reaction { class BubbleReaction extends StatelessWidget { /// list of reactions to display final List reactions; - + /// callback when a reaction is tapped final Function(Reaction)? onReactionTap; - + /// callback when add reaction button is tapped final VoidCallback? onAddReactionTap; - + /// whether to show the add reaction button final bool showAddButton; - + /// background color of reaction chips final Color backgroundColor; - + /// background color of user's own reactions final Color userReactionColor; - + /// text color for reaction count final Color textColor; - + /// border color for reaction chips final Color? borderColor; - + /// size of the emoji final double emojiSize; - + /// padding inside each reaction chip final EdgeInsets chipPadding; - + /// spacing between reaction chips final double spacing; - + /// border radius of reaction chips final double borderRadius; - + /// whether reactions are aligned to the right (for sender messages) final bool alignRight; @@ -239,22 +239,22 @@ class _AddReactionButton extends StatelessWidget { class ReactionPicker extends StatelessWidget { /// list of available emoji reactions final List reactions; - + /// callback when a reaction is selected final Function(String)? onReactionSelected; - + /// background color of the picker final Color backgroundColor; - + /// size of each emoji final double emojiSize; - + /// padding around the picker final EdgeInsets padding; - + /// border radius of the picker final double borderRadius; - + /// spacing between emojis final double spacing; @@ -318,13 +318,13 @@ class ReactionPicker extends StatelessWidget { class ReactionOverlay extends StatefulWidget { /// the child widget (typically a chat bubble) final Widget child; - + /// list of available reactions final List reactions; - + /// callback when a reaction is selected final Function(String)? onReactionSelected; - + /// whether to enable the reaction overlay final bool enabled; diff --git a/lib/swipe/swipeable_bubble.dart b/lib/swipe/swipeable_bubble.dart index 8cec063..caa9858 100644 --- a/lib/swipe/swipeable_bubble.dart +++ b/lib/swipe/swipeable_bubble.dart @@ -112,8 +112,7 @@ class _SwipeableBubbleState extends State _dragX = newDragX; }); - final bool thresholdCrossed = - _dragX.abs() >= widget.swipeThreshold; + final bool thresholdCrossed = _dragX.abs() >= widget.swipeThreshold; if (thresholdCrossed && !_hapticFired && widget.enableHaptics) { HapticFeedback.mediumImpact(); diff --git a/lib/utils/bubble_status_row.dart b/lib/utils/bubble_status_row.dart index 1ba205a..c5ec8e2 100644 --- a/lib/utils/bubble_status_row.dart +++ b/lib/utils/bubble_status_row.dart @@ -30,8 +30,7 @@ class BubbleStatusRow extends StatelessWidget { @override Widget build(BuildContext context) { - final bool hasContent = - isEdited || timestamp != null || stateIcon != null; + final bool hasContent = isEdited || timestamp != null || stateIcon != null; if (!hasContent) return const SizedBox.shrink(); return Row( diff --git a/test/algo/date_chip_text_test.dart b/test/algo/date_chip_text_test.dart new file mode 100644 index 0000000..9f1dec5 --- /dev/null +++ b/test/algo/date_chip_text_test.dart @@ -0,0 +1,21 @@ +import 'package:chat_bubbles/algo/algo.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('Algo.dateChipText', () { + test('returns "Today" for the current date', () { + expect(Algo.dateChipText(DateTime.now()), 'Today'); + }); + + test('returns "Yesterday" for one day ago', () { + final now = DateTime.now(); + final yesterday = DateTime(now.year, now.month, now.day - 1); + expect(Algo.dateChipText(yesterday), 'Yesterday'); + }); + + test('returns formatted "d MMMM y" for older dates', () { + final old = DateTime(2024, 1, 15); + expect(Algo.dateChipText(old), '15 January 2024'); + }); + }); +} diff --git a/test/bubbles/bubble_link_preview_test.dart b/test/bubbles/bubble_link_preview_test.dart new file mode 100644 index 0000000..5f5ea91 --- /dev/null +++ b/test/bubbles/bubble_link_preview_test.dart @@ -0,0 +1,24 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('BubbleLinkPreview', () { + testWidgets('renders url, title and description', (tester) async { + await pumpInApp( + tester, + const BubbleLinkPreview( + url: 'https://example.com', + title: 'Example', + description: 'An example domain', + text: 'Check this out', + ), + ); + + expect(find.text('Example'), findsOneWidget); + expect(find.text('An example domain'), findsOneWidget); + expect(find.text('Check this out'), findsOneWidget); + }); + }); +} diff --git a/test/bubbles/bubble_normal_audio_test.dart b/test/bubbles/bubble_normal_audio_test.dart new file mode 100644 index 0000000..9271695 --- /dev/null +++ b/test/bubbles/bubble_normal_audio_test.dart @@ -0,0 +1,78 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('BubbleNormalAudio', () { + testWidgets('renders without crashing in non-loading state', + (tester) async { + await pumpInApp( + tester, + BubbleNormalAudio( + duration: 30000, + position: 5000, + isLoading: false, + onPlayPauseButtonClick: () {}, + onSeekChanged: (_) {}, + ), + ); + + expect(find.byType(BubbleNormalAudio), findsOneWidget); + }); + + testWidgets('fires onPlayPauseButtonClick when the play icon is tapped', + (tester) async { + var clicks = 0; + await pumpInApp( + tester, + BubbleNormalAudio( + duration: 30000, + position: 0, + isLoading: false, + isPlaying: false, + onPlayPauseButtonClick: () => clicks++, + onSeekChanged: (_) {}, + ), + ); + + await tester.tap(find.byIcon(Icons.play_arrow)); + await tester.pump(); + + expect(clicks, 1); + }); + + testWidgets('renders a slider when waveformData is null', (tester) async { + await pumpInApp( + tester, + BubbleNormalAudio( + duration: 30000, + position: 0, + isLoading: false, + onPlayPauseButtonClick: () {}, + onSeekChanged: (_) {}, + ), + ); + + expect(find.byType(Slider), findsOneWidget); + }); + + testWidgets('omits the slider when waveformData is provided', + (tester) async { + await pumpInApp( + tester, + BubbleNormalAudio( + duration: 30000, + position: 0, + isLoading: false, + onPlayPauseButtonClick: () {}, + onSeekChanged: (_) {}, + waveformData: List.generate(20, (i) => i / 20), + ), + ); + + expect(find.byType(Slider), findsNothing); + }); + }); +} diff --git a/test/bubbles/bubble_normal_image_test.dart b/test/bubbles/bubble_normal_image_test.dart new file mode 100644 index 0000000..1fc962d --- /dev/null +++ b/test/bubbles/bubble_normal_image_test.dart @@ -0,0 +1,48 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('BubbleNormalImage', () { + testWidgets('renders the provided image widget', (tester) async { + await pumpInApp( + tester, + BubbleNormalImage( + id: 'img-1', + image: Container( + key: const Key('test-image'), + width: 100, + height: 100, + color: Colors.blue, + ), + ), + ); + + expect(find.byKey(const Key('test-image')), findsOneWidget); + }); + + testWidgets('fires onTap when the image is tapped', (tester) async { + var taps = 0; + await pumpInApp( + tester, + BubbleNormalImage( + id: 'img-2', + image: Container( + key: const Key('tap-target'), + width: 100, + height: 100, + color: Colors.red, + ), + onTap: () => taps++, + ), + ); + + await tester.tap(find.byKey(const Key('tap-target'))); + await tester.pump(); + + expect(taps, 1); + }); + }); +} diff --git a/test/bubbles/bubble_normal_test.dart b/test/bubbles/bubble_normal_test.dart new file mode 100644 index 0000000..99d3443 --- /dev/null +++ b/test/bubbles/bubble_normal_test.dart @@ -0,0 +1,70 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('BubbleNormal', () { + testWidgets('renders the provided text', (tester) async { + await pumpInApp(tester, const BubbleNormal(text: 'Hello world')); + + expect(find.text('Hello world'), findsOneWidget); + }); + + testWidgets('adds a leading Expanded spacer when isSender is true', + (tester) async { + // Sender bubbles push to the right with an Expanded spacer on the left. + await pumpInApp( + tester, + const BubbleNormal(text: 'mine', isSender: true), + ); + + expect(find.byType(Expanded), findsOneWidget); + }); + + testWidgets('renders no Expanded spacer when isSender is false', + (tester) async { + // Receiver bubbles have no Expanded — they hug the left edge. + await pumpInApp( + tester, + const BubbleNormal(text: 'theirs', isSender: false), + ); + + expect(find.byType(Expanded), findsNothing); + }); + + testWidgets('fires onTap when tapped outside the SelectableText', + (tester) async { + var tapped = 0; + await pumpInApp( + tester, + // isSender: false so the bubble hugs the left edge — sender bubbles + // have an Expanded spacer that absorbs a centered tap on the Row. + BubbleNormal( + text: 'tap me', + isSender: false, + onTap: () => tapped++, + ), + ); + + // The bubble wraps its text in a SelectableText, which consumes taps + // for text selection. To exercise the outer GestureDetector.onTap, tap + // in the bubble's padding area just above the text. + final textRect = tester.getRect(find.byType(SelectableText)); + await tester.tapAt(Offset(textRect.center.dx, textRect.top - 3)); + await tester.pump(); + + expect(tapped, 1); + }); + + testWidgets('renders timestamp when provided', (tester) async { + await pumpInApp( + tester, + const BubbleNormal(text: 'with time', timestamp: '12:30 PM'), + ); + + expect(find.text('12:30 PM'), findsOneWidget); + }); + }); +} diff --git a/test/bubbles/bubble_reply_test.dart b/test/bubbles/bubble_reply_test.dart new file mode 100644 index 0000000..9d00645 --- /dev/null +++ b/test/bubbles/bubble_reply_test.dart @@ -0,0 +1,42 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('BubbleReply', () { + testWidgets('renders text, replied message and sender', (tester) async { + await pumpInApp( + tester, + const BubbleReply( + repliedMessage: 'original message', + repliedMessageSender: 'Alice', + text: 'my reply', + ), + ); + + expect(find.text('original message'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); + expect(find.text('my reply'), findsOneWidget); + }); + + testWidgets('fires onReplyTap when the quoted block is tapped', + (tester) async { + var tapped = 0; + await pumpInApp( + tester, + BubbleReply( + repliedMessage: 'original', + repliedMessageSender: 'Alice', + text: 'reply', + onReplyTap: () => tapped++, + ), + ); + + await tester.tap(find.text('original')); + await tester.pump(); + + expect(tapped, 1); + }); + }); +} diff --git a/test/bubbles/bubble_special_test.dart b/test/bubbles/bubble_special_test.dart new file mode 100644 index 0000000..165833b --- /dev/null +++ b/test/bubbles/bubble_special_test.dart @@ -0,0 +1,27 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('BubbleSpecialOne', () { + testWidgets('renders text', (tester) async { + await pumpInApp(tester, const BubbleSpecialOne(text: 'one')); + expect(find.text('one'), findsOneWidget); + }); + }); + + group('BubbleSpecialTwo', () { + testWidgets('renders text', (tester) async { + await pumpInApp(tester, const BubbleSpecialTwo(text: 'two')); + expect(find.text('two'), findsOneWidget); + }); + }); + + group('BubbleSpecialThree', () { + testWidgets('renders text', (tester) async { + await pumpInApp(tester, const BubbleSpecialThree(text: 'three')); + expect(find.text('three'), findsOneWidget); + }); + }); +} diff --git a/test/chatbubbles_test.dart b/test/chatbubbles_test.dart deleted file mode 100644 index ab73b3a..0000000 --- a/test/chatbubbles_test.dart +++ /dev/null @@ -1 +0,0 @@ -void main() {} diff --git a/test/date_chips/date_chip_test.dart b/test/date_chips/date_chip_test.dart new file mode 100644 index 0000000..ac09792 --- /dev/null +++ b/test/date_chips/date_chip_test.dart @@ -0,0 +1,20 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('DateChip', () { + testWidgets('renders "Today" for the current date', (tester) async { + await pumpInApp(tester, DateChip(date: DateTime.now())); + + expect(find.text('Today'), findsOneWidget); + }); + + testWidgets('renders a formatted date for older dates', (tester) async { + await pumpInApp(tester, DateChip(date: DateTime(2024, 1, 15))); + + expect(find.text('15 January 2024'), findsOneWidget); + }); + }); +} diff --git a/test/groups/bubble_group_builder_test.dart b/test/groups/bubble_group_builder_test.dart new file mode 100644 index 0000000..2f69fd7 --- /dev/null +++ b/test/groups/bubble_group_builder_test.dart @@ -0,0 +1,49 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('BubbleGroupBuilder', () { + testWidgets('returns an empty box when itemCount is zero', (tester) async { + await pumpInApp( + tester, + BubbleGroupBuilder( + itemCount: 0, + senderIdOf: (_) => 'x', + itemBuilder: (_, __, ___) => const SizedBox.shrink(), + ), + ); + + expect(find.byType(BubbleGroupBuilder), findsOneWidget); + }); + + testWidgets('calls itemBuilder once per item with grouping info', + (tester) async { + final senders = ['alice', 'alice', 'bob']; + final infos = []; + + await pumpInApp( + tester, + BubbleGroupBuilder( + itemCount: senders.length, + senderIdOf: (i) => senders[i], + itemBuilder: (_, i, info) { + infos.add(info); + return Text('msg-$i'); + }, + ), + ); + + expect(find.text('msg-0'), findsOneWidget); + expect(find.text('msg-1'), findsOneWidget); + expect(find.text('msg-2'), findsOneWidget); + + expect(infos, hasLength(3)); + expect(infos[0].isGroupStart, isTrue); + expect(infos[1].isGroupEnd, isTrue); + expect(infos[2].isAlone, isTrue); + }); + }); +} diff --git a/test/groups/message_group_helper_test.dart b/test/groups/message_group_helper_test.dart new file mode 100644 index 0000000..d3dc9dc --- /dev/null +++ b/test/groups/message_group_helper_test.dart @@ -0,0 +1,74 @@ +import 'package:chat_bubbles/groups/message_group_helper.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('MessageGroupHelper.compute', () { + test('returns empty list for empty input', () { + expect(MessageGroupHelper.compute(senderIds: []), isEmpty); + }); + + test('marks a lone message as alone with tail and avatar', () { + final result = MessageGroupHelper.compute(senderIds: ['alice']); + + expect(result, hasLength(1)); + expect(result[0].isAlone, isTrue); + expect(result[0].showTail, isTrue); + expect(result[0].showAvatar, isTrue); + expect(result[0].isGroupStart, isTrue); + expect(result[0].isGroupEnd, isTrue); + }); + + test('groups consecutive messages from the same sender', () { + final result = MessageGroupHelper.compute( + senderIds: ['alice', 'alice', 'alice'], + ); + + expect(result[0].isGroupStart, isTrue); + expect(result[0].isGroupEnd, isFalse); + expect(result[0].showTail, isFalse); + + expect(result[1].isGroupStart, isFalse); + expect(result[1].isGroupEnd, isFalse); + + expect(result[2].isGroupStart, isFalse); + expect(result[2].isGroupEnd, isTrue); + expect(result[2].showTail, isTrue); + }); + + test('splits a group when the sender changes', () { + final result = MessageGroupHelper.compute( + senderIds: ['alice', 'alice', 'bob', 'alice'], + ); + + expect(result[0].isGroupStart, isTrue); + expect(result[1].isGroupEnd, isTrue); + expect(result[2].isAlone, isTrue); + expect(result[3].isAlone, isTrue); + }); + + test('splits a group when timestamps exceed the threshold', () { + final base = DateTime(2024, 1, 1, 12, 0); + final result = MessageGroupHelper.compute( + senderIds: ['alice', 'alice'], + timestamps: [base, base.add(const Duration(minutes: 5))], + threshold: const Duration(minutes: 1), + ); + + expect(result[0].isAlone, isTrue); + expect(result[1].isAlone, isTrue); + }); + + test('keeps a group when timestamps are within the threshold', () { + final base = DateTime(2024, 1, 1, 12, 0); + final result = MessageGroupHelper.compute( + senderIds: ['alice', 'alice'], + timestamps: [base, base.add(const Duration(seconds: 30))], + threshold: const Duration(minutes: 1), + ); + + expect(result[0].isGroupStart, isTrue); + expect(result[0].isGroupEnd, isFalse); + expect(result[1].isGroupEnd, isTrue); + }); + }); +} diff --git a/test/indicators/typing_indicator_test.dart b/test/indicators/typing_indicator_test.dart new file mode 100644 index 0000000..5001216 --- /dev/null +++ b/test/indicators/typing_indicator_test.dart @@ -0,0 +1,34 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('TypingIndicator', () { + testWidgets('renders when showIndicator is true', (tester) async { + await pumpInApp(tester, const TypingIndicator()); + + expect(find.byType(TypingIndicator), findsOneWidget); + + // pump a frame to advance the animation, then settle by stopping + await tester.pump(const Duration(milliseconds: 100)); + }); + + testWidgets('renders nothing visible when showIndicator is false', + (tester) async { + await pumpInApp(tester, const TypingIndicator(showIndicator: false)); + + expect(find.byType(TypingIndicator), findsOneWidget); + }); + }); + + group('TypingIndicatorWave', () { + testWidgets('renders without crashing', (tester) async { + await pumpInApp(tester, const TypingIndicatorWave()); + + expect(find.byType(TypingIndicatorWave), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 100)); + }); + }); +} diff --git a/test/message_bars/message_bar_test.dart b/test/message_bars/message_bar_test.dart new file mode 100644 index 0000000..dc8975b --- /dev/null +++ b/test/message_bars/message_bar_test.dart @@ -0,0 +1,87 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('MessageBar', () { + testWidgets('renders the default hint text', (tester) async { + await pumpInApp(tester, MessageBar()); + + expect(find.text('Type your message here'), findsOneWidget); + }); + + testWidgets('renders a custom hint when provided', (tester) async { + await pumpInApp( + tester, + MessageBar(messageBarHintText: 'Say something'), + ); + + expect(find.text('Say something'), findsOneWidget); + }); + + testWidgets('shows the reply preview when replying is true', + (tester) async { + await pumpInApp( + tester, + MessageBar(replying: true, replyingTo: 'Alice'), + ); + + expect(find.text('Re : Alice'), findsOneWidget); + }); + + testWidgets('fires onSend with the entered text', (tester) async { + String? sent; + await pumpInApp(tester, MessageBar(onSend: (text) => sent = text)); + + await tester.enterText(find.byType(TextField), 'Hello'); + await tester.tap(find.byIcon(Icons.send)); + await tester.pump(); + + expect(sent, 'Hello'); + }); + + testWidgets('uses the custom sendButton widget when provided', + (tester) async { + await pumpInApp( + tester, + MessageBar( + sendButton: const Icon( + Icons.arrow_upward, + key: Key('custom-send'), + ), + ), + ); + + expect(find.byKey(const Key('custom-send')), findsOneWidget); + expect(find.byIcon(Icons.send), findsNothing); + }); + }); + + group('MessageBarStyle', () { + test('exposes the documented defaults', () { + const style = MessageBarStyle(); + + expect(style.fillColor, Colors.white); + expect(style.minLines, 1); + expect(style.maxLines, 3); + expect(style.keyboardType, TextInputType.multiline); + expect(style.textCapitalization, TextCapitalization.sentences); + }); + + testWidgets('applied min/maxLines reach the underlying TextField', + (tester) async { + await pumpInApp( + tester, + MessageBar( + messageBarStyle: const MessageBarStyle(minLines: 2, maxLines: 6), + ), + ); + + final field = tester.widget(find.byType(TextField)); + expect(field.minLines, 2); + expect(field.maxLines, 6); + }); + }); +} diff --git a/test/reactions/bubble_reaction_test.dart b/test/reactions/bubble_reaction_test.dart new file mode 100644 index 0000000..63aca37 --- /dev/null +++ b/test/reactions/bubble_reaction_test.dart @@ -0,0 +1,75 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('BubbleReaction', () { + testWidgets('renders one chip per reaction with count > 1', (tester) async { + await pumpInApp( + tester, + const BubbleReaction( + reactions: [ + Reaction(emoji: '❤️', count: 3), + Reaction(emoji: '😂', count: 1), + ], + showAddButton: false, + ), + ); + + expect(find.text('❤️'), findsOneWidget); + expect(find.text('😂'), findsOneWidget); + // count is only rendered when > 1 + expect(find.text('3'), findsOneWidget); + expect(find.text('1'), findsNothing); + }); + + testWidgets('fires onAddReactionTap when the add button is tapped', + (tester) async { + var taps = 0; + await pumpInApp( + tester, + BubbleReaction( + reactions: const [], + onAddReactionTap: () => taps++, + ), + ); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + expect(taps, 1); + }); + + testWidgets('renders nothing when reactions are empty and add is hidden', + (tester) async { + await pumpInApp( + tester, + const BubbleReaction(reactions: [], showAddButton: false), + ); + + expect(find.byType(SizedBox), findsWidgets); + expect(find.byType(Chip), findsNothing); + }); + }); + + group('ReactionPicker', () { + testWidgets('fires onReactionSelected with the tapped emoji', + (tester) async { + String? selected; + await pumpInApp( + tester, + ReactionPicker( + reactions: const ['❤️', '👍'], + onReactionSelected: (emoji) => selected = emoji, + ), + ); + + await tester.tap(find.text('👍')); + await tester.pump(); + + expect(selected, '👍'); + }); + }); +} diff --git a/test/swipe/swipeable_bubble_test.dart b/test/swipe/swipeable_bubble_test.dart new file mode 100644 index 0000000..e26ef66 --- /dev/null +++ b/test/swipe/swipeable_bubble_test.dart @@ -0,0 +1,58 @@ +import 'package:chat_bubbles/chat_bubbles.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../test_utils.dart'; + +void main() { + group('SwipeableBubble', () { + testWidgets('renders its child', (tester) async { + await pumpInApp( + tester, + const SwipeableBubble( + child: Text('child content'), + ), + ); + + expect(find.text('child content'), findsOneWidget); + }); + + testWidgets('fires onSwipeRight when dragged right past the threshold', + (tester) async { + var rightSwipes = 0; + await pumpInApp( + tester, + SwipeableBubble( + enableHaptics: false, + swipeThreshold: 50, + onSwipeRight: () => rightSwipes++, + child: const SizedBox(width: 200, height: 60, child: Text('drag')), + ), + ); + + await tester.drag(find.text('drag'), const Offset(120, 0)); + await tester.pumpAndSettle(); + + expect(rightSwipes, 1); + }); + + testWidgets('fires onSwipeLeft when dragged left past the threshold', + (tester) async { + var leftSwipes = 0; + await pumpInApp( + tester, + SwipeableBubble( + enableHaptics: false, + swipeThreshold: 50, + onSwipeLeft: () => leftSwipes++, + child: const SizedBox(width: 200, height: 60, child: Text('drag')), + ), + ); + + await tester.drag(find.text('drag'), const Offset(-120, 0)); + await tester.pumpAndSettle(); + + expect(leftSwipes, 1); + }); + }); +} diff --git a/test/test_utils.dart b/test/test_utils.dart new file mode 100644 index 0000000..e79c10a --- /dev/null +++ b/test/test_utils.dart @@ -0,0 +1,6 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future pumpInApp(WidgetTester tester, Widget child) { + return tester.pumpWidget(MaterialApp(home: Scaffold(body: child))); +}