diff --git a/CHANGELOG.md b/CHANGELOG.md index 662c4ee8..f891df9c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## [2.4.2] (unreleased) +* **Breaking**: [318](https://github.com/SimformSolutionsPvtLtd/flutter_chatview/issues/318) + Provide support for action item widgets on the chat text field. Also, provide a way to add plus/attach + button to open the overlay for action items. * **Fix**: [266](https://github.com/SimformSolutionsPvtLtd/flutter_chatview/issues/266) Fix the keyboard overlapping the text field * **Feat**: [296](https://github.com/SimformSolutionsPvtLtd/flutter_chatview/issues/296) diff --git a/doc/documentation.md b/doc/documentation.md index e47682b8..ac6e2560 100644 --- a/doc/documentation.md +++ b/doc/documentation.md @@ -530,182 +530,48 @@ real-time messaging with supporting media uploads. # Migration Guide -## Migration Guide for ChatView 2.0.0 -This guide will help you migrate your code from previous versions of ChatView to version 2.0.0. +## Migration Guide for ChatView 3.0.0 +This guide will help you migrate your code from previous versions of ChatView to version 3.0.0. ## Key Changes -### Renamed Properties +### Add action item widgets for text field -- Renamed `sendBy` field to `sentBy` in `Message` class. -- Renamed `chatUsers` field to `otherUsers` in `ChatController` class. +- Add `CameraActionButton` and `GalleryActionButton` widgets to the text field for camera and gallery actions. +- Now, you can add overlay action button to show overlay action items in the text field. + - With multiple action items -### Moved Properties +To show camera and gallery action items in the text field, you can use `textFieldActionWidgets`. -- Moved `currentUser` field from `ChatView` widget to `ChatController` class. - -### Updated Methods - -- Updated `id` value in `copyWith` method of `Message` to have correct value. -- Removed `showTypingIndicator` field from `ChatView` and replaced it with `ChatController.showTypingIndicator`. - -### JSON Serialization Changes - -The format for `fromJson` and `toJson` methods changed for several classes: - -#### ChatUser - -**Before (`ChatUser.fromJson`):** -```dart -ChatUser.fromJson( - { - ... - 'imageType': ImageType.asset, - ... - }, -), -``` - -**After (`ChatUser.fromJson`):** -```dart -ChatUser.fromJson( - { - ... - 'imageType': 'asset', - ... - }, -), -``` - -**Before (`ChatUser.toJson`):** -```dart -{ - ... - imageType: ImageType.asset, - ... -} -``` - -**After (`ChatUser.toJson`):** -```dart -{ - ... - imageType: asset, - ... -} -``` - -#### Message - -**Before (`Message.fromJson`):** -```dart -Message.fromJson( - { - ... - 'createdAt': DateTime.now(), - 'message_type': MessageType.text, - 'voice_message_duration': Duration(seconds: 5), - ... - } -) -``` - -**After (`Message.fromJson`):** -```dart -Message.fromJson( - { - ... - 'createdAt': '2024-06-13T17:32:19.586412', - 'message_type': 'text', - 'voice_message_duration': '5000000', - ... - } -) -``` - -**Before (`Message.toJson`):** -```dart -{ - ... - createdAt: 2024-06-13 17:23:19.454789, - message_type: MessageType.text, - voice_message_duration: 0:00:05.000000, - ... -} -``` - -**After (`Message.toJson`):** -```dart -{ - ... - createdAt: 2024-06-13T17:32:19.586412, - message_type: text, - voice_message_duration: 5000000, - ... -} -``` - -#### ReplyMessage - -**Before (`ReplyMessage.fromJson`):** -```dart -ReplyMessage.fromJson( - { - ... - 'message_type': MessageType.text, - 'voiceMessageDuration': Duration(seconds: 5), - ... - } -) -``` - -**After (`ReplyMessage.fromJson`):** ```dart -ReplyMessage.fromJson( - { - ... - 'message_type': 'text', - 'voiceMessageDuration': '5000000', - ... - } -) -``` - -**Before (`ReplyMessage.toJson`):** -```dart -{ - ... - message_type: MessageType.text, - voiceMessageDuration: 0:00:05.000000, - ... -} -``` - -**After (`ReplyMessage.toJson`):** -```dart -{ - ... - message_type: text, - voiceMessageDuration: 5000000, - ... -} -``` - -## Typing Indicator Changes - -**Before:** -```dart -ChatView( - showTypingIndicator: false, +... +textFieldConfig: TextFieldConfiguration( + textFieldActionWidgets: [ + CameraActionButton( + icon: const Icon( + Icons.camera_alt, + ), + onPressed: (path) { + if (path != null) { + _onSendTap(path, const ReplyMessage(), MessageType.image); + } + }, + ), + GalleryActionButton( + icon: const Icon( + Icons.photo_library, + ), + onPressed: (path) { + if (path != null) { + _onSendTap(path, const ReplyMessage(), MessageType.image); + } + }, + ), + ], ), +... ``` -**After:** -```dart -// Use it with your ChatController instance -_chatController.setTypingIndicator = true; // for showing indicator -_chatController.setTypingIndicator = false; // for hiding indicator -``` # Contributors diff --git a/example/lib/main.dart b/example/lib/main.dart index 26214180..10292cb6 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -43,28 +43,28 @@ class _ChatScreenState extends State { _chatController = ChatController( initialMessageList: Data.messageList, scrollController: ScrollController(), - currentUser: ChatUser( + currentUser: const ChatUser( id: '1', name: 'Flutter', profilePhoto: Data.profileImage, ), otherUsers: [ - ChatUser( + const ChatUser( id: '2', name: 'Simform', profilePhoto: Data.profileImage, ), - ChatUser( + const ChatUser( id: '3', name: 'Jhon', profilePhoto: Data.profileImage, ), - ChatUser( + const ChatUser( id: '4', name: 'Mike', profilePhoto: Data.profileImage, ), - ChatUser( + const ChatUser( id: '5', name: 'Rich', profilePhoto: Data.profileImage, diff --git a/lib/chatview.dart b/lib/chatview.dart index c84b049f..0c61303b 100644 --- a/lib/chatview.dart +++ b/lib/chatview.dart @@ -39,5 +39,9 @@ export 'src/utils/chat_view_locale.dart'; export 'src/utils/package_strings.dart'; export 'src/values/enumeration.dart'; export 'src/values/typedefs.dart'; +export 'src/widgets/action_widgets/camera_action_button.dart'; +export 'src/widgets/action_widgets/gallery_action_button.dart'; +export 'src/widgets/action_widgets/overlay_action_button.dart'; +export 'src/widgets/action_widgets/text_field_action_button.dart'; export 'src/widgets/chat_view.dart'; export 'src/widgets/chat_view_appbar.dart'; diff --git a/lib/src/models/config_models/send_message_configuration.dart b/lib/src/models/config_models/send_message_configuration.dart index 07c725e0..18fbe87e 100644 --- a/lib/src/models/config_models/send_message_configuration.dart +++ b/lib/src/models/config_models/send_message_configuration.dart @@ -21,9 +21,9 @@ */ import 'package:audio_waveforms/audio_waveforms.dart'; +import 'package:chatview_utils/chatview_utils.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:chatview_utils/chatview_utils.dart'; import 'package:image_picker/image_picker.dart'; import '../../values/typedefs.dart'; @@ -62,12 +62,6 @@ class SendMessageConfiguration { /// Enable/disable voice recording. Enabled by default. final bool allowRecordingVoice; - /// Enable/disable image picker from gallery. Enabled by default. - final bool enableGalleryImagePicker; - - /// Enable/disable send image from camera. Enabled by default. - final bool enableCameraImagePicker; - /// Color of mic icon when replying to some voice message. final Color? micIconColor; @@ -89,8 +83,6 @@ class SendMessageConfiguration { this.replyMessageColor, this.closeIconColor, this.allowRecordingVoice = true, - this.enableCameraImagePicker = true, - this.enableGalleryImagePicker = true, this.voiceRecordingConfiguration, this.micIconColor, this.cancelRecordConfiguration, @@ -168,6 +160,17 @@ class TextFieldConfiguration { /// Default is [true]. final bool enabled; + /// List of widgets to be shown as action widget in text field. + final List? textFieldTrailingActionWidgets; + + /// List of widgets to be shown as leading action widget in text field. + final List? textFieldLeadingActionWidgets; + + /// hint text max lines in text field. + final int? hintMaxLines; + + final bool hideLeadingActionsOnType; + const TextFieldConfiguration({ this.contentPadding, this.maxLines, @@ -184,6 +187,10 @@ class TextFieldConfiguration { this.inputFormatters, this.textCapitalization, this.enabled = true, + this.textFieldLeadingActionWidgets, + this.textFieldTrailingActionWidgets, + this.hintMaxLines, + this.hideLeadingActionsOnType = true, }); } diff --git a/lib/src/models/models.dart b/lib/src/models/models.dart index b86a8bf5..4a7512e2 100644 --- a/lib/src/models/models.dart +++ b/lib/src/models/models.dart @@ -20,24 +20,25 @@ * SOFTWARE. */ export 'chat_bubble.dart'; +export 'config_models/chat_bubble_configuration.dart'; +export 'config_models/chat_view_states_configuration.dart'; +export 'config_models/emoji_message_configuration.dart'; +export 'config_models/feature_active_config.dart'; export 'config_models/image_message_configuration.dart'; +export 'config_models/link_preview_configuration.dart'; +export 'config_models/message_configuration.dart'; +export 'config_models/message_list_configuration.dart'; export 'config_models/message_reaction_configuration.dart'; export 'config_models/profile_circle_configuration.dart'; -export 'config_models/chat_bubble_configuration.dart'; -export 'config_models/replied_message_configuration.dart'; -export 'config_models/swipe_to_reply_configuration.dart'; -export 'config_models/reply_popup_configuration.dart'; export 'config_models/reaction_popup_configuration.dart'; -export 'config_models/message_list_configuration.dart'; -export 'config_models/emoji_message_configuration.dart'; -export 'config_models/message_configuration.dart'; -export 'config_models/send_message_configuration.dart'; -export 'config_models/link_preview_configuration.dart'; -export 'config_models/type_indicator_configuration.dart'; -export 'config_models/chat_view_states_configuration.dart'; +export 'config_models/replied_message_configuration.dart'; export 'config_models/replied_msg_auto_scroll_config.dart'; -export 'config_models/feature_active_config.dart'; +export 'config_models/reply_popup_configuration.dart'; export 'config_models/reply_suggestions_config.dart'; -export 'config_models/suggestion_list_config.dart'; export 'config_models/scroll_to_bottom_button_config.dart'; +export 'config_models/send_message_configuration.dart'; +export 'config_models/suggestion_list_config.dart'; +export 'config_models/swipe_to_reply_configuration.dart'; +export 'config_models/type_indicator_configuration.dart'; export 'config_models/voice_message_configuration.dart'; +export 'overlay_action_widget.dart'; diff --git a/lib/src/models/overlay_action_widget.dart b/lib/src/models/overlay_action_widget.dart new file mode 100644 index 00000000..c1ddad92 --- /dev/null +++ b/lib/src/models/overlay_action_widget.dart @@ -0,0 +1,15 @@ +import 'package:chatview/chatview.dart'; +import 'package:flutter/material.dart'; + +/// Represents a widget that can be used in the plus/attach modal sheet. +class OverlayActionWidget { + final Widget icon; + final String label; + final VoidCallBack onTap; + + const OverlayActionWidget({ + required this.icon, + required this.label, + required this.onTap, + }); +} diff --git a/lib/src/utils/helper.dart b/lib/src/utils/helper.dart new file mode 100644 index 00000000..76216796 --- /dev/null +++ b/lib/src/utils/helper.dart @@ -0,0 +1,21 @@ +import 'package:chatview/chatview.dart'; +import 'package:image_picker/image_picker.dart'; + +Future onMediaActionButtonPressed( + ImageSource source, { + ImagePickerConfiguration? config, +}) async { + try { + final XFile? image = await ImagePicker().pickImage( + source: source, + maxHeight: config?.maxHeight, + maxWidth: config?.maxWidth, + imageQuality: config?.imageQuality, + preferredCameraDevice: config?.preferredCameraDevice ?? CameraDevice.rear, + ); + final imagePath = await config?.onImagePicked?.call(image?.path); + return imagePath ?? image?.path; + } catch (e) { + return null; + } +} diff --git a/lib/src/values/enumeration.dart b/lib/src/values/enumeration.dart index 014651b1..925a4d88 100644 --- a/lib/src/values/enumeration.dart +++ b/lib/src/values/enumeration.dart @@ -22,8 +22,8 @@ // Different types Message of ChatView -import 'package:flutter/material.dart'; import 'package:chatview_utils/chatview_utils.dart'; +import 'package:flutter/material.dart'; enum ShowReceiptsIn { all, lastMessage } diff --git a/lib/src/widgets/action_widgets/camera_action_button.dart b/lib/src/widgets/action_widgets/camera_action_button.dart new file mode 100644 index 00000000..2718d713 --- /dev/null +++ b/lib/src/widgets/action_widgets/camera_action_button.dart @@ -0,0 +1,35 @@ +import 'package:chatview/chatview.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../utils/helper.dart'; + +/// Camera action button implementation. +class CameraActionButton extends TextFieldActionButton { + CameraActionButton({ + super.key, + required super.icon, + super.color, + required ValueSetter? onPressed, + this.imagePickerConfiguration, + }) : super( + onPressed: onPressed == null + ? null + : () async { + FocusManager.instance.primaryFocus?.unfocus(); + final path = await onMediaActionButtonPressed( + ImageSource.camera, + config: imagePickerConfiguration, + ); + onPressed.call(path); + }, + ); + + final ImagePickerConfiguration? imagePickerConfiguration; + + @override + State createState() => _CameraActionButtonState(); +} + +class _CameraActionButtonState + extends TextFieldActionButtonState {} diff --git a/lib/src/widgets/action_widgets/gallery_action_button.dart b/lib/src/widgets/action_widgets/gallery_action_button.dart new file mode 100644 index 00000000..747798fc --- /dev/null +++ b/lib/src/widgets/action_widgets/gallery_action_button.dart @@ -0,0 +1,50 @@ +import 'dart:io'; + +import 'package:chatview/chatview.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; + +import '../../utils/helper.dart'; + +/// Gallery action button implementation. +class GalleryActionButton extends TextFieldActionButton { + GalleryActionButton({ + super.key, + required super.icon, + required ValueSetter? onPressed, + super.color, + this.imagePickerConfiguration, + }) : super( + onPressed: onPressed == null + ? null + : () async { + final primaryFocus = FocusManager.instance.primaryFocus; + final hasFocus = primaryFocus?.hasFocus ?? false; + primaryFocus?.unfocus(); + final path = await onMediaActionButtonPressed( + ImageSource.gallery, + config: imagePickerConfiguration, + ); + // To maintain the iOS native behavior of text field, + // When the user taps on the gallery icon, and the text field has focus, + // the keyboard should close. + // We need to request focus again to open the keyboard. + // This is not required for Android. + // This is a workaround for the issue where the keyboard remain open and overlaps the text field. + + // https://github.com/SimformSolutionsPvtLtd/chatview/issues/266 + if (Platform.isIOS && hasFocus) { + primaryFocus?.requestFocus(); + } + onPressed.call(path); + }, + ); + + final ImagePickerConfiguration? imagePickerConfiguration; + + @override + State createState() => _GalleryActionButtonState(); +} + +class _GalleryActionButtonState + extends TextFieldActionButtonState {} diff --git a/lib/src/widgets/action_widgets/overlay_action_button.dart b/lib/src/widgets/action_widgets/overlay_action_button.dart new file mode 100644 index 00000000..b01d49df --- /dev/null +++ b/lib/src/widgets/action_widgets/overlay_action_button.dart @@ -0,0 +1,237 @@ +import 'package:chatview/chatview.dart'; +import 'package:flutter/material.dart'; + +/// Custom action button that displays a plus icon and shows a horizontal row of options when pressed. +class OverlayActionButton extends TextFieldActionButton { + const OverlayActionButton({ + super.key, + required super.icon, + super.onPressed, + super.color, + required this.actions, + this.isLeading = false, + }); + + /// List of actions to display in the overlay. + final List actions; + + /// Whether the overlay should be anchored to the leading edge (left) or trailing edge (right). + final bool isLeading; + + @override + State createState() => _OverlayActionButtonState(); +} + +class _OverlayActionButtonState + extends TextFieldActionButtonState + with SingleTickerProviderStateMixin { + late OverlayEntry _plusOverlayEntry; + late AnimationController _overlayAnimationController; + late Animation _overlayOffsetAnimation; + final GlobalKey _plusIconKey = GlobalKey(); + + @override + void initState() { + _overlayAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 250), + ); + // Create the slide animation for the overlay + _overlayOffsetAnimation = Tween( + begin: const Offset(0, 1), + end: Offset.zero, + ).animate( + // Use a curved animation for smooth transition + CurvedAnimation( + parent: _overlayAnimationController, + curve: Curves.easeOut, + ), + ); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return IconButton( + key: _plusIconKey, + icon: widget.icon, + color: widget.color, + onPressed: () { + widget.onPressed != null + ? widget.onPressed?.call() + : _showOverlay( + widget.actions, + plusIconKey: _plusIconKey, + isLeading: widget.isLeading, // Pass isLeading + ); + }, + ); + } + + @override + void dispose() { + _overlayAnimationController.dispose(); + super.dispose(); + } + + /// Shows a horizontal row of options above the plus icon. + /// - If all options fit, overlay width matches content exactly (no extra space) + /// - If too many options, overlay is scrollable and takes max width + /// - Overlay is always anchored above the plus icon and never overflows screen + void _showOverlay( + List plusOptions, { + GlobalKey? plusIconKey, + bool isLeading = false, + }) { + _overlayAnimationController.reset(); + + var overlayBottom = 0.0; + var overlayLeft = 0.0; + + final screenHeight = MediaQuery.sizeOf(context).height; + var overlayWidth = MediaQuery.sizeOf(context).width; + const horizontalPadding = 24.0; // Margin from screen edge + var iconLeft = 0.0; + var iconRight = 0.0; + var optionButtonWidth = 0.0; + + if (plusIconKey?.currentContext case final context?) { + final renderObject = context.findRenderObject(); + if (renderObject != null && renderObject is RenderBox) { + final RenderBox iconBox = renderObject; + final Offset iconOffset = iconBox.localToGlobal(Offset.zero); + // Plus icon width + optionButtonWidth = iconBox.size.width; + // Left edge of the plus icon for leading + iconLeft = iconOffset.dx; + // Right edge of the plus icon + iconRight = iconOffset.dx + optionButtonWidth; + // Calculate overlay bottom based on icon position + overlayBottom = screenHeight - iconOffset.dy; + } + } + + (overlayLeft, overlayWidth) = _getOverlaySize( + plusOptions: plusOptions, + iconLeft: iconLeft, + iconRight: iconRight, + horizontalPadding: horizontalPadding, + optionButtonWidth: optionButtonWidth, + screenWidth: overlayWidth, + isLeading: isLeading, + ); + + _plusOverlayEntry = OverlayEntry( + builder: (context) { + return GestureDetector( + onTap: hideOverlay, // Dismiss overlay on outside tap + child: Material( + color: Colors.transparent, + child: Stack( + children: [ + Positioned( + left: overlayLeft, + bottom: overlayBottom, + width: overlayWidth, + child: SlideTransition( + position: _overlayOffsetAnimation, + child: Container( + // Overlay container with animation, shadow, and rounded corners + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: const BorderRadius.all( + Radius.circular(20), + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withAlpha((0.08 * 255).round()), + blurRadius: 12, + ), + ], + ), + child: SizedBox( + height: 48, + child: ListView.builder( + scrollDirection: Axis.horizontal, + reverse: !isLeading, // Reverse for trailing + itemCount: plusOptions.length, + itemBuilder: (context, index) { + final option = plusOptions[index]; + return IconButton( + icon: option.icon, + tooltip: option.label, + onPressed: () { + hideOverlay(); + option.onTap.call(); + }, + ); + }, + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + Overlay.of(context, rootOverlay: true).insert(_plusOverlayEntry); + _overlayAnimationController.forward(); + } + + (double, double) _getOverlaySize({ + required List plusOptions, + required double iconLeft, + required double iconRight, + required double horizontalPadding, + required double optionButtonWidth, + required double screenWidth, + required bool isLeading, + }) { + var overlayWidth = 0.0; + var overlayLeft = 0.0; + // Calculate total width of all options + final totalOptionsWidth = plusOptions.length * optionButtonWidth; + // Calculate the maximum width for the overlay + // - 24 for horizontal padding on each side (left and right) + // Multiply by 2 for both sides to account extra padding on right side + // So, overlay shown above of plus icon + final maxOverlayWidth = screenWidth - horizontalPadding * 4; + + // - If all options fit, overlay is just wide enough and right-aligned to plus icon + // - If leading icon, overlay is left-aligned to plus icon + // - If not, overlay is scrollable and takes max width + if (totalOptionsWidth < maxOverlayWidth) { + // Overlay just wide enough for options + overlayWidth = totalOptionsWidth; + // Align overlay to plus icon + // If leading, align left edge of overlay to left edge of plus icon + // If trailing, align right edge of overlay to right edge of plus icon + overlayLeft = isLeading ? iconLeft : iconRight - overlayWidth; + } else { + // Overlay takes full width, scrollable + overlayWidth = maxOverlayWidth; + if (isLeading) { + // Always align left edge of overlay to left edge of plus icon + overlayLeft = iconLeft; + // If leading, ensure overlay doesn't overflow right side + // Ensure overlay does not overflow right side + overlayWidth -= overlayLeft - horizontalPadding * 2; + } else { + // For trailing, keep plus icon at right edge of overlay, but don't overflow left + overlayLeft = (iconRight - overlayWidth); + } + } + + return (overlayLeft, overlayWidth); + } + + // Dismisses the plus/attach overlay with animation and cleans up the entry. + void hideOverlay() async { + await _overlayAnimationController.reverse(); + _plusOverlayEntry.remove(); + } +} diff --git a/lib/src/widgets/action_widgets/text_field_action_button.dart b/lib/src/widgets/action_widgets/text_field_action_button.dart new file mode 100644 index 00000000..85aaeae4 --- /dev/null +++ b/lib/src/widgets/action_widgets/text_field_action_button.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; + +/// A generic action button for text fields that can be used for various actions like opening the camera, or selecting images from the gallery. +class TextFieldActionButton extends StatefulWidget { + final Icon icon; + final Color? color; + final VoidCallback? onPressed; + + const TextFieldActionButton({ + super.key, + required this.icon, + this.color, + this.onPressed, + }); + + @override + State createState() => TextFieldActionButtonState(); +} + +class TextFieldActionButtonState + extends State { + @override + Widget build(BuildContext context) { + return IconButton( + onPressed: widget.onPressed, + icon: widget.icon, + color: widget.color, + ); + } +} diff --git a/lib/src/widgets/chatui_textfield.dart b/lib/src/widgets/chatui_textfield.dart index 8e51827c..f2c89be5 100644 --- a/lib/src/widgets/chatui_textfield.dart +++ b/lib/src/widgets/chatui_textfield.dart @@ -27,7 +27,6 @@ import 'package:chatview/src/utils/constants/constants.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:image_picker/image_picker.dart'; import '../../chatview.dart'; import '../utils/debounce.dart'; @@ -40,7 +39,6 @@ class ChatUITextField extends StatefulWidget { required this.textEditingController, required this.onPressed, required this.onRecordingComplete, - required this.onImageSelected, }) : super(key: key); /// Provides configuration of default text field in chat. @@ -58,9 +56,6 @@ class ChatUITextField extends StatefulWidget { /// Provides callback once voice is recorded. final Function(String?) onRecordingComplete; - /// Provides callback when user select images from camera/gallery. - final StringsCallBack onImageSelected; - @override State createState() => _ChatUITextFieldState(); } @@ -68,8 +63,6 @@ class ChatUITextField extends StatefulWidget { class _ChatUITextFieldState extends State { final ValueNotifier _inputText = ValueNotifier(''); - final ImagePicker _imagePicker = ImagePicker(); - RecorderController? controller; ValueNotifier isRecording = ValueNotifier(false); @@ -224,39 +217,66 @@ class _ChatUITextFieldState extends State { ) else Expanded( - child: TextField( - focusNode: widget.focusNode, - controller: widget.textEditingController, - style: textFieldConfig?.textStyle ?? - const TextStyle(color: Colors.white), - maxLines: textFieldConfig?.maxLines ?? 5, - minLines: textFieldConfig?.minLines ?? 1, - keyboardType: textFieldConfig?.textInputType, - inputFormatters: textFieldConfig?.inputFormatters, - onChanged: _onChanged, - enabled: textFieldConfig?.enabled, - textCapitalization: textFieldConfig?.textCapitalization ?? - TextCapitalization.sentences, - decoration: InputDecoration( - hintText: textFieldConfig?.hintText ?? - PackageStrings.currentLocale.message, - fillColor: sendMessageConfig?.textFieldBackgroundColor ?? - Colors.white, - filled: true, - hintStyle: textFieldConfig?.hintStyle ?? - TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Colors.grey.shade600, - letterSpacing: 0.25, + child: Row( + children: [ + if (textFieldConfig?.hideLeadingActionsOnType ?? false) + ValueListenableBuilder( + valueListenable: _inputText, + builder: (context, value, child) { + if (value.isNotEmpty) { + return const SizedBox.shrink(); + } else { + return Row( + children: textFieldConfig + ?.textFieldLeadingActionWidgets ?? + [], + ); + } + }, + ) + else + ...?textFieldConfig?.textFieldLeadingActionWidgets, + Expanded( + child: TextField( + focusNode: widget.focusNode, + controller: widget.textEditingController, + style: textFieldConfig?.textStyle ?? + const TextStyle(color: Colors.white), + maxLines: textFieldConfig?.maxLines ?? 5, + minLines: textFieldConfig?.minLines ?? 1, + keyboardType: textFieldConfig?.textInputType, + inputFormatters: textFieldConfig?.inputFormatters, + onChanged: _onChanged, + enabled: textFieldConfig?.enabled, + textCapitalization: + textFieldConfig?.textCapitalization ?? + TextCapitalization.sentences, + decoration: InputDecoration( + hintText: textFieldConfig?.hintText ?? + PackageStrings.currentLocale.message, + fillColor: + sendMessageConfig?.textFieldBackgroundColor ?? + Colors.white, + filled: true, + hintMaxLines: textFieldConfig?.hintMaxLines ?? 1, + hintStyle: textFieldConfig?.hintStyle ?? + TextStyle( + overflow: TextOverflow.ellipsis, + fontSize: 16, + fontWeight: FontWeight.w400, + color: Colors.grey.shade600, + letterSpacing: 0.25, + ), + contentPadding: textFieldConfig?.contentPadding ?? + const EdgeInsets.symmetric(horizontal: 6), + border: outlineBorder, + focusedBorder: outlineBorder, + enabledBorder: outlineBorder, + disabledBorder: outlineBorder, ), - contentPadding: textFieldConfig?.contentPadding ?? - const EdgeInsets.symmetric(horizontal: 6), - border: outlineBorder, - focusedBorder: outlineBorder, - enabledBorder: outlineBorder, - disabledBorder: outlineBorder, - ), + ), + ), + ], ), ), ValueListenableBuilder( @@ -278,46 +298,10 @@ class _ChatUITextFieldState extends State { } else { return Row( children: [ - if (!isRecordingValue) ...[ - if (sendMessageConfig?.enableCameraImagePicker ?? - true) - IconButton( - constraints: const BoxConstraints(), - onPressed: (textFieldConfig?.enabled ?? true) - ? () => _onIconPressed( - ImageSource.camera, - config: sendMessageConfig - ?.imagePickerConfiguration, - ) - : null, - icon: imagePickerIconsConfig - ?.cameraImagePickerIcon ?? - Icon( - Icons.camera_alt_outlined, - color: - imagePickerIconsConfig?.cameraIconColor, - ), - ), - if (sendMessageConfig?.enableGalleryImagePicker ?? - true) - IconButton( - constraints: const BoxConstraints(), - onPressed: (textFieldConfig?.enabled ?? true) - ? () => _onIconPressed( - ImageSource.gallery, - config: sendMessageConfig - ?.imagePickerConfiguration, - ) - : null, - icon: imagePickerIconsConfig - ?.galleryImagePickerIcon ?? - Icon( - Icons.image, - color: imagePickerIconsConfig - ?.galleryIconColor, - ), - ), - ], + if (!isRecordingValue) + ...?textFieldConfig?.textFieldTrailingActionWidgets, + + // Always add the voice button at the end if allowed if ((sendMessageConfig?.allowRecordingVoice ?? false) && !kIsWeb && (Platform.isIOS || Platform.isAndroid)) @@ -334,6 +318,7 @@ class _ChatUITextFieldState extends State { voiceRecordingConfig?.recorderIconColor, ), ), + if (isRecordingValue && cancelRecordConfiguration != null) IconButton( @@ -401,44 +386,6 @@ class _ChatUITextFieldState extends State { } } - void _onIconPressed( - ImageSource imageSource, { - ImagePickerConfiguration? config, - }) async { - final hasFocus = widget.focusNode.hasFocus; - try { - widget.focusNode.unfocus(); - final XFile? image = await _imagePicker.pickImage( - source: imageSource, - maxHeight: config?.maxHeight, - maxWidth: config?.maxWidth, - imageQuality: config?.imageQuality, - preferredCameraDevice: - config?.preferredCameraDevice ?? CameraDevice.rear, - ); - String? imagePath = image?.path; - if (config?.onImagePicked != null) { - String? updatedImagePath = await config?.onImagePicked!(imagePath); - if (updatedImagePath != null) imagePath = updatedImagePath; - } - widget.onImageSelected(imagePath ?? '', ''); - } catch (e) { - widget.onImageSelected('', e.toString()); - } finally { - // To maintain the iOS native behavior of text field, - // When the user taps on the gallery icon, and the text field has focus, - // the keyboard should close. - // We need to request focus again to open the keyboard. - // This is not required for Android. - // This is a workaround for the issue where the keyboard remain open and overlaps the text field. - - // https://github.com/SimformSolutionsPvtLtd/chatview/issues/266 - if (imageSource == ImageSource.gallery && Platform.isIOS && hasFocus) { - widget.focusNode.requestFocus(); - } - } - } - void _onChanged(String inputText) { debouncer.run(() { composingStatus.value = TypeWriterStatus.typed; diff --git a/lib/src/widgets/message_view.dart b/lib/src/widgets/message_view.dart index 2d1215f5..548deda0 100644 --- a/lib/src/widgets/message_view.dart +++ b/lib/src/widgets/message_view.dart @@ -23,11 +23,11 @@ import 'package:chatview/chatview.dart'; import 'package:chatview/src/widgets/chat_view_inherited_widget.dart'; import 'package:flutter/material.dart'; -import 'package:chatview/src/extensions/extensions.dart'; +import '../extensions/extensions.dart'; import '../utils/constants/constants.dart'; import 'image_message_view.dart'; -import 'text_message_view.dart'; import 'reaction_widget.dart'; +import 'text_message_view.dart'; import 'voice_message_view.dart'; class MessageView extends StatefulWidget { diff --git a/lib/src/widgets/send_message_widget.dart b/lib/src/widgets/send_message_widget.dart index f97614a8..9413d653 100644 --- a/lib/src/widgets/send_message_widget.dart +++ b/lib/src/widgets/send_message_widget.dart @@ -261,7 +261,6 @@ class SendMessageWidgetState extends State { onPressed: _onPressed, sendMessageConfig: widget.sendMessageConfig, onRecordingComplete: _onRecordingComplete, - onImageSelected: _onImageSelected, ) ], ), @@ -282,14 +281,6 @@ class SendMessageWidgetState extends State { } } - void _onImageSelected(String imagePath, String error) { - debugPrint('Call in Send Message Widget'); - if (imagePath.isNotEmpty) { - widget.onSendTap.call(imagePath, replyMessage, MessageType.image); - _assignRepliedMessage(); - } - } - void _assignRepliedMessage() { if (replyMessage.message.isNotEmpty) { _replyMessage.value = const ReplyMessage();