diff --git a/example/android/app/src/main/AndroidManifest.xml b/example/android/app/src/main/AndroidManifest.xml index 2b09eff..887153e 100644 --- a/example/android/app/src/main/AndroidManifest.xml +++ b/example/android/app/src/main/AndroidManifest.xml @@ -1,9 +1,27 @@ + + - - - - + + + + + + + + + + + + + + + + + + + - + + android:name="io.flutter.embedding.android.NormalTheme" + android:resource="@style/NormalTheme" /> + - + + - diff --git a/example/lib/main.dart b/example/lib/main.dart index 5abd0e7..e11362a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,94 +1,69 @@ -import 'dart:developer'; - import 'package:chat_package/chat_package.dart'; import 'package:chat_package/models/chat_message.dart'; -import 'package:chat_package/models/media/chat_media.dart'; -import 'package:chat_package/models/media/media_type.dart'; import 'package:flutter/material.dart'; -void main() { - runApp(MyApp()); -} +void main() => runApp(const MyApp()); class MyApp extends StatelessWidget { + const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( - title: 'chat ui example', + title: 'Chat Ui example', theme: ThemeData( - primarySwatch: Colors.blue, + primaryColor: const Color(0xFF075E54), + scaffoldBackgroundColor: Colors.white, ), - home: MyHomePage(), + home: const ChatPage(), ); } } -class MyHomePage extends StatefulWidget { - MyHomePage({Key? key}) : super(key: key); +class ChatPage extends StatefulWidget { + const ChatPage({super.key}); @override - _MyHomePageState createState() => _MyHomePageState(); + State createState() => _ChatPageState(); } -class _MyHomePageState extends State { - List messages = [ +class _ChatPageState extends State { + final textEditingController = TextEditingController(); + @override + void dispose() { + textEditingController.dispose(); + super.dispose(); + } + + final messages = [ ChatMessage( + text: 'hi omar', isSender: true, - text: 'this is a banana', - chatMedia: ChatMedia( - url: - 'https://images.pexels.com/photos/7194915/pexels-photo-7194915.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260', - mediaType: MediaType.imageMediaType(), - ), ), - ChatMessage( - isSender: false, - chatMedia: ChatMedia( - url: - 'https://images.pexels.com/photos/7194915/pexels-photo-7194915.jpeg?auto=compress&cs=tinysrgb&h=750&w=1260', - mediaType: MediaType.imageMediaType(), - ), - ), - ChatMessage(isSender: false, text: 'wow that is cool'), ]; - final scrollController = ScrollController(); + @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(), + resizeToAvoidBottomInset: true, + appBar: AppBar( + title: const Text('Chat'), + backgroundColor: const Color(0xFF075E54), + ), body: ChatScreen( - scrollController: scrollController, messages: messages, - onSlideToCancelRecord: () { - log('not sent'); + scrollController: ScrollController(), + onRecordComplete: (audioMessage) { + messages.add(audioMessage); + setState(() {}); }, - onTextSubmit: (textMessage) { - setState(() { - messages.add(textMessage); - - scrollController - .jumpTo(scrollController.position.maxScrollExtent + 50); - }); - }, - handleRecord: (audioMessage, canceled) { - if (!canceled) { - setState(() { - messages.add(audioMessage!); - scrollController - .jumpTo(scrollController.position.maxScrollExtent + 90); - }); - } + onImageSelected: (imageMessage) { + messages.add(imageMessage); + setState(() {}); }, - handleImageSelect: (imageMessage) async { - if (imageMessage != null) { - setState(() { - messages.add( - imageMessage, - ); - scrollController - .jumpTo(scrollController.position.maxScrollExtent + 300); - }); - } + textEditingController: TextEditingController(), + onTextSubmit: (textMessage) { + messages.add(textMessage); + setState(() {}); }, ), ); diff --git a/example/pubspec.lock b/example/pubspec.lock index 9160cbf..1472e85 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -184,7 +184,7 @@ packages: source: hosted version: "4.0.2" image_picker: - dependency: transitive + dependency: "direct main" description: name: image_picker sha256: b6951e25b795d053a6ba03af5f710069c99349de9341af95155d52665cb4607c @@ -248,7 +248,7 @@ packages: source: hosted version: "0.2.1+1" intl: - dependency: transitive + dependency: "direct main" description: name: intl sha256: "910f85bce16fb5c6f614e117efa303e85a1731bb0081edf3604a2ae6e9a3cc91" @@ -256,7 +256,7 @@ packages: source: hosted version: "0.17.0" just_audio: - dependency: transitive + dependency: "direct main" description: name: just_audio sha256: f978d5b4ccea08f267dae0232ec5405c1b05d3f3cd63f82097ea46c015d5c09e @@ -360,7 +360,7 @@ packages: source: hosted version: "1.9.0" path_provider: - dependency: transitive + dependency: "direct main" description: name: path_provider sha256: "50c5dd5b6e1aaf6fb3a78b33f6aa3afca52bf903a8a5298f53101fdaee55bbcd" @@ -408,7 +408,7 @@ packages: source: hosted version: "2.3.0" permission_handler: - dependency: transitive + dependency: "direct main" description: name: permission_handler sha256: bc56bfe9d3f44c3c612d8d393bd9b174eb796d706759f9b495ac254e4294baa5 @@ -448,7 +448,7 @@ packages: source: hosted version: "0.1.3" photo_view: - dependency: transitive + dependency: "direct main" description: name: photo_view sha256: "8036802a00bae2a78fc197af8a158e3e2f7b500561ed23b4c458107685e645bb" @@ -480,7 +480,7 @@ packages: source: hosted version: "6.1.5" record: - dependency: transitive + dependency: "direct main" description: name: record sha256: daeb3f9b3fea9797094433fe6e49a879d8e4ca4207740bc6dc7e4a58764f0817 @@ -581,7 +581,7 @@ packages: source: hosted version: "1.11.1" stop_watch_timer: - dependency: transitive + dependency: "direct main" description: name: stop_watch_timer sha256: eb690e4f6d983ba12ddfbf51a5d489fdba8b64982b9651cb538650888cec2886 diff --git a/example/pubspec.yaml b/example/pubspec.yaml index c9b0c1c..70d6ee6 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -1,90 +1,34 @@ name: xx description: "A new Flutter project." -# The following line prevents the package from being accidentally published to -# pub.dev using `flutter pub publish`. This is preferred for private packages. -publish_to: "none" # Remove this line if you wish to publish to pub.dev +publish_to: "none" -# The following defines the version and build number for your application. -# A version number is three numbers separated by dots, like 1.2.43 -# followed by an optional build number separated by a +. -# Both the version and the builder number may be overridden in flutter -# build by specifying --build-name and --build-number, respectively. -# In Android, build-name is used as versionName while build-number used as versionCode. -# Read more about Android versioning at https://developer.android.com/studio/publish/versioning -# In iOS, build-name is used as CFBundleShortVersionString while build-number is used as CFBundleVersion. -# Read more about iOS versioning at -# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html -# In Windows, build-name is used as the major, minor, and patch parts -# of the product and file versions while build-number is used as the build suffix. version: 1.0.0+1 environment: sdk: ^3.5.2 -# Dependencies specify other packages that your package needs in order to work. -# To automatically upgrade your package dependencies to the latest versions -# consider running `flutter pub upgrade --major-versions`. Alternatively, -# dependencies can be manually updated by changing the version numbers below to -# the latest version available on pub.dev. To see which dependencies have newer -# versions available, run `flutter pub outdated`. dependencies: flutter: sdk: flutter - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 chat_package: path: ../. + #todo delete + permission_handler: ^10.2.0 + image_picker: ^0.8.6 + just_audio: ^0.9.30 + record: ^6.0.0 + intl: ^0.17.0 + photo_view: ^0.14.0 + stop_watch_timer: ^2.0.0 + path_provider: ^2.0.15 dev_dependencies: flutter_test: sdk: flutter - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^4.0.0 -# For information on the generic Dart part of this file, see the -# following page: https://dart.dev/tools/pub/pubspec - -# The following section is specific to Flutter packages. flutter: - # The following line ensures that the Material Icons font is - # included with your application, so that you can use the icons in - # the material Icons class. uses-material-design: true - - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg - - # An image asset can refer to one or more resolution-specific "variants", see - # https://flutter.dev/to/resolution-aware-images - - # For details regarding adding assets from package dependencies, see - # https://flutter.dev/to/asset-from-package - - # To add custom fonts to your application, add a fonts section here, - # in this "flutter" section. Each entry in this list should have a - # "family" key with the font family name, and a "fonts" key with a - # list giving the asset and other descriptors for the font. For - # example: - # fonts: - # - family: Schyler - # fonts: - # - asset: fonts/Schyler-Regular.ttf - # - asset: fonts/Schyler-Italic.ttf - # style: italic - # - family: Trajan Pro - # fonts: - # - asset: fonts/TrajanPro.ttf - # - asset: fonts/TrajanPro_Bold.ttf - # weight: 700 - # - # For details regarding fonts from package dependencies, - # see https://flutter.dev/to/font-from-package diff --git a/lib/chat_package.dart b/lib/chat_package.dart index 583daa8..528753a 100644 --- a/lib/chat_package.dart +++ b/lib/chat_package.dart @@ -1,187 +1,253 @@ +/// lib/src/presentation/widgets/chat_screen.dart library chat_package; +import 'package:flutter/material.dart'; +import 'package:chat_package/components/chat_input_field/chat_input_field.dart'; +import 'package:chat_package/components/chat_input_field/widgets/recording_button.dart'; +import 'package:chat_package/components/chat_input_field/widgets/wave_animation.dart'; import 'package:chat_package/components/message/message_widget.dart'; import 'package:chat_package/models/chat_message.dart'; import 'package:chat_package/utils/constants.dart'; -import 'package:chat_package/components/chat_input_field/chat_input_field.dart'; -import 'package:flutter/material.dart'; -class ChatScreen extends StatefulWidget { - ///color of all message containers if its belongs to the user - final Color? senderColor; +/// A full-screen chat UI consisting of a message list and an input field. +/// +/// Displays [messages] in a scrollable list and a [ChatInputField] at the bottom. +/// Supports text, image, and audio messages, with customizable styling and callbacks. +/// +/// Example: +/// ```dart +/// ChatScreen( +/// messages: messages, +/// scrollController: _scrollController, +/// textEditingController: _textController, +/// onTextSubmit: (msg) => sendText(msg), +/// onImageSelected: (msg) => sendImage(msg), +/// onRecordComplete: (msg) => sendAudio(msg), +/// ); +/// ``` +class ChatScreen extends StatelessWidget { + /// Creates a new [ChatScreen]. + /// + /// Requires [messages], [scrollController], [textEditingController], + /// [onTextSubmit], [onImageSelected], and [onRecordComplete]. + const ChatScreen({ + Key? key, - ///color of the inactive part of the audio slider - final Color? inActiveAudioSliderColor; + /// The list of chat messages to display. + required this.messages, - ///color of the active part of the audio slider - final Color? activeAudioSliderColor; + /// Controller for the message list scrolling. + required this.scrollController, - ///[required]scrollController for the chat screen - final ScrollController scrollController; + /// Controller for the text input field. + required this.textEditingController, + + /// Callback invoked when a text message is submitted. + required this.onTextSubmit, - /// the color of the outer container and the color used to hide - /// the text on slide - final Color chatInputFieldColor; + /// Callback invoked when an image message is selected. + required this.onImageSelected, - ///hint text to be shown for sending messages - final String sendMessageHintText; + /// Callback invoked when audio recording is completed. + required this.onRecordComplete, - /// these parameters for changing the text and icons in the [attachment-bottom-sheet] - /// text shown wen trying to chose image attachment from gallery in attachment - /// bottom sheet - final String imageAttachmentFromGalleryText; + /// Flag to enable or disable user input. + this.enableInput = true, - /// Icon shown wen trying to chose image attachment from gallery in attachment - /// bottom sheet - final Icon? imageAttachmentFromGalleryIcon; + /// Hint text displayed when recording audio. + this.recordingNoteHintText = 'Now Recording', - /// text shown wen trying to chose image attachment from camera in attachment - /// bottom sheet - final String imageAttachmentFromCameraText; + /// Text for the “From Camera” option in the attachment sheet. + this.cameraText = 'From Camera', - /// Icon shown wen trying to chose image attachment from camera in attachment - /// bottom sheet - final Icon? imageAttachmentFromCameraIcon; + /// Icon for the “From Camera” option. + this.cameraIcon, - /// text shown wen trying to chose image attachment cancel text in attachment - /// bottom sheet - final String imageAttachmentCancelText; + /// Text for the “From Gallery” option. + this.galleryText = 'From Gallery', - /// Icon shown wen trying to chose image attachment cancel text in attachment - /// bottom sheet - final Icon? imageAttachmentCancelIcon; + /// Icon for the “From Gallery” option. + this.galleryIcon, - /// image attachment text style in attachment - /// bottom sheet - final TextStyle? imageAttachmentTextStyle; + /// Text for the “Cancel” option. + this.cancelText = 'Cancel', - ///hint text to be shown for recording voice note - final String recordingNoteHintText; + /// Icon for the “Cancel” option. + this.cancelIcon, + + /// Style for the option labels in the bottom sheet. + this.chatBottomSheetTextStyle, + + /// Decoration for the text input field. + this.textFieldDecoration, - /// [required] handel [text message] on submit - /// this method will pass a [ChatMessage] - final Function(ChatMessage textMessage) onTextSubmit; + /// Padding around the chat field. + this.chatFieldPadding, - /// [required] the list of chat messages + /// Margin around the chat field. + this.chatFieldMargin, + + /// Whether to show the wave animation during recording. + this.showWaveAnimation = true, + + /// Duration of the wave animation. + this.waveDuration, + + /// Style of the wave animation. + this.waveStyle, + + /// Style of the recording button. + this.buttonStyle, + + /// Text direction for the input field. + this.textDirection, + + /// Color used for sender message bubble. + this.senderColor, + + /// Color used for receiver message bubble. + this.receiverColor, + + /// Active color for the audio slider. + this.activeAudioSliderColor, + + /// Inactive color for the audio slider. + this.inactiveAudioSliderColor, + }) : super(key: key); + + /// The list of chat messages to display. final List messages; - /// [required] function to handel successful recordings, bass to override - /// this method will pass a [ChatMessage] and if the used [canceled] the recording - final Function(ChatMessage? audioMessage, bool canceled) handleRecord; + /// Controller for the message list scrolling. + final ScrollController scrollController; - /// [required] function to handel image selection - /// this method will pass a [ChatMessage] - final Function(ChatMessage? imageMessage) handleImageSelect; + /// Controller for the text input field. + final TextEditingController textEditingController; - /// to handel canceling of the record - final VoidCallback? onSlideToCancelRecord; + /// Callback invoked when a text message is submitted. + final ValueChanged onTextSubmit; - ///TextEditingController to handel input text - final TextEditingController? textEditingController; + /// Callback invoked when an image message is selected. + final ValueChanged onImageSelected; - /// to change the appearance of the chat input field - final BoxDecoration? chatInputFieldDecoration; + /// Callback invoked when audio recording is completed. + final ValueChanged onRecordComplete; - /// use this flag to disable the input - final bool disableInput; + /// Flag to enable or disable user input. + final bool enableInput; - /// git the chat input field padding - final EdgeInsets? chatInputFieldPadding; + /// Hint text displayed when recording audio. + final String recordingNoteHintText; + + /// Text for the “From Camera” option in the attachment sheet. + final String cameraText; + + /// Icon for the “From Camera” option. + final Icon? cameraIcon; + + /// Text for the “From Gallery” option. + final String galleryText; + + /// Icon for the “From Gallery” option. + final Icon? galleryIcon; + + /// Text for the “Cancel” option. + final String cancelText; + + /// Icon for the “Cancel” option. + final Icon? cancelIcon; + + /// Style for the option labels in the bottom sheet. + final TextStyle? chatBottomSheetTextStyle; - /// text style for the message container - final TextStyle? messageContainerTextStyle; + /// Decoration for the text input field. + final InputDecoration? textFieldDecoration; - /// text style for the message container date - final TextStyle? sendDateTextStyle; + /// Padding around the chat field. + final EdgeInsetsGeometry? chatFieldPadding; - /// text direction for chat input field + /// Margin around the chat field. + final EdgeInsetsGeometry? chatFieldMargin; + + /// Whether to show the wave animation during recording. + final bool showWaveAnimation; + + /// Duration of the wave animation. + final Duration? waveDuration; + + /// Style of the wave animation. + final WaveAnimationStyle? waveStyle; + + /// Style of the recording button. + final RecordingButtonStyle? buttonStyle; + + /// Text direction for the input field. final TextDirection? textDirection; - /// this is an optional parameter to override the default attachment bottom sheet - final Function(BuildContext context)? attachmentClick; + /// Color used for sender message bubble. + final Color? senderColor; - ChatScreen({ - Key? key, - this.senderColor, - this.inActiveAudioSliderColor, - this.activeAudioSliderColor, - required this.messages, - required this.scrollController, - this.sendMessageHintText = 'Enter message here', - this.recordingNoteHintText = 'Now Recording', - this.imageAttachmentFromGalleryText = 'From Gallery', - this.imageAttachmentFromCameraText = 'From Camera', - this.imageAttachmentCancelText = 'Cancel', - this.chatInputFieldColor = const Color(0xFFCFD8DC), - this.imageAttachmentTextStyle, - required this.handleRecord, - required this.handleImageSelect, - this.onSlideToCancelRecord, - this.textEditingController, - this.disableInput = false, - this.chatInputFieldDecoration, - required this.onTextSubmit, - this.chatInputFieldPadding, - this.imageAttachmentFromGalleryIcon, - this.imageAttachmentFromCameraIcon, - this.imageAttachmentCancelIcon, - this.messageContainerTextStyle, - this.sendDateTextStyle, - this.attachmentClick, - this.textDirection, - }) : super(key: key); + /// Color used for receiver message bubble. + final Color? receiverColor; - @override - _ChatScreenState createState() => _ChatScreenState(); -} -class _ChatScreenState extends State { + /// Active color for the audio slider. + final Color? activeAudioSliderColor; + + /// Inactive color for the audio slider. + final Color? inactiveAudioSliderColor; + @override Widget build(BuildContext context) { return Stack( - children: [ + children: [ ListView.builder( - padding: const EdgeInsets.only( - left: kDefaultPadding, right: kDefaultPadding, bottom: 100), - controller: widget.scrollController, - itemCount: widget.messages.length, - itemBuilder: (context, index) => MessageWidget( - message: widget.messages[index], - activeAudioSliderColor: - widget.activeAudioSliderColor ?? kSecondaryColor, - inActiveAudioSliderColor: - widget.inActiveAudioSliderColor ?? kLightColor, - senderColor: widget.senderColor ?? kPrimaryColor, - messageContainerTextStyle: widget.messageContainerTextStyle, - sendDateTextStyle: widget.sendDateTextStyle, - ), + padding: const EdgeInsets.symmetric( + horizontal: kDefaultPadding, + vertical: kDefaultPadding / 2, + ).copyWith(bottom: 100), + controller: scrollController, + itemCount: messages.length, + itemBuilder: (context, index) { + final message = messages[index]; + return MessageWidget( + receiverColor: receiverColor ?? kSecondaryColor, + senderColor: senderColor ?? kPrimaryColor, + activeAudioSliderColor: activeAudioSliderColor ?? kSecondaryColor, + inactiveAudioSliderColor: inactiveAudioSliderColor ?? kLightColor, + message: message, + messageContainerTextStyle: null, + sendDateTextStyle: null, + ); + }, ), Positioned( bottom: 20, left: 5, right: 5, child: ChatInputField( - imageAttachmentCancelText: widget.imageAttachmentCancelText, - imageAttachmentFromCameraText: widget.imageAttachmentFromCameraText, - imageAttachmentFromGalleryText: - widget.imageAttachmentFromGalleryText, - chatInputFieldColor: widget.chatInputFieldColor, - recordingNoteHintText: widget.recordingNoteHintText, - sendMessageHintText: widget.sendMessageHintText, - disableInput: widget.disableInput, - chatInputFieldDecoration: widget.chatInputFieldDecoration, - chatInputFieldPadding: widget.chatInputFieldPadding, - imageAttachmentTextStyle: widget.imageAttachmentTextStyle, - imageAttachmentFromGalleryIcon: - widget.imageAttachmentFromGalleryIcon, - imageAttachmentFromCameraIcon: widget.imageAttachmentFromCameraIcon, - imageAttachmentCancelIcon: widget.imageAttachmentCancelIcon, - attachmentClick: widget.attachmentClick, - handleRecord: widget.handleRecord, - handleImageSelect: widget.handleImageSelect, - onSlideToCancelRecord: widget.onSlideToCancelRecord ?? () {}, - onTextSubmit: widget.onTextSubmit, - textDirection: widget.textDirection, + textController: textEditingController, + onTextSubmit: onTextSubmit, + onImageSelected: onImageSelected, + onRecordComplete: onRecordComplete, + enableInput: enableInput, + recordingNoteHintText: recordingNoteHintText, + cameraText: cameraText, + cameraIcon: cameraIcon, + galleryText: galleryText, + galleryIcon: galleryIcon, + cancelText: cancelText, + cancelIcon: cancelIcon, + chatBottomSheetTextStyle: chatBottomSheetTextStyle, + textFieldDecoration: textFieldDecoration, + chatFieldPadding: chatFieldPadding, + chatFieldMargin: chatFieldMargin, + showWaveAnimation: showWaveAnimation, + waveDuration: waveDuration, + waveStyle: waveStyle, + buttonStyle: buttonStyle, + textDirection: textDirection, + ), ), ], diff --git a/lib/components/chat_input_field/chat_input_field.dart b/lib/components/chat_input_field/chat_input_field.dart index c47aacb..445050b 100644 --- a/lib/components/chat_input_field/chat_input_field.dart +++ b/lib/components/chat_input_field/chat_input_field.dart @@ -1,225 +1,277 @@ import 'package:chat_package/components/chat_input_field/chat_input_field_provider.dart'; -import 'package:chat_package/components/chat_input_field/widgets/chat_animated_button.dart'; -import 'package:chat_package/components/chat_input_field/widgets/chat_attachment_bottom_sheet.dart'; -import 'package:chat_package/components/chat_input_field/widgets/chat_input_field_container_widget.dart'; +import 'package:chat_package/components/chat_input_field/widgets/chat_bottom_sheet.dart'; +import 'package:chat_package/components/chat_input_field/widgets/recording_button.dart'; +import 'package:chat_package/components/chat_input_field/widgets/wave_animation.dart'; import 'package:chat_package/models/chat_message.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'widgets/chat_drag_trail.dart'; -class ChatInputField extends StatelessWidget { - /// Height of the slider. Defaults to 70. - final double buttonRadios; +/// A chat input field with text entry and press‑and‑hold + slide‑to‑cancel recording. +/// All visuals and durations can be customized via constructor parameters. +class ChatInputField extends StatefulWidget { + /// Called when non‑empty text is submitted. - /// The button widget used on the moving element of the slider. Defaults to Icon(Icons.chevron_right). - final Widget sliderButtonContent; + /// Called when an audio recording successfully completes. + final ValueChanged onRecordComplete; - /// hint text to be shown for sending messages - final String sendMessageHintText; + /// Invoked with a [ChatMessage] when an image is picked, or `null` on cancel. + final ValueChanged onImageSelected; - /// hit text to be shown for recording voice note - final String recordingNoteHintText; + /// Controller for the text input. + final TextEditingController textController; - /// The Icon showed to send a text - final IconData sendTextIcon; + /// Horizontal drag distance (in px) to cancel recording. + final double cancelThreshold; - /// text shown wen trying to chose image attachment from gallery - final String imageAttachmentFromGalleryText; + /// Whether to show the wave animation during recording. + final bool showWaveAnimation; - /// Icon shown wen trying to chose image attachment from gallery - final Icon? imageAttachmentFromGalleryIcon; + /// Duration of the wave animation cycle. + final Duration waveDuration; - /// text shown wen trying to chose image attachment from camera - final String imageAttachmentFromCameraText; + /// Padding inside the main container. + final EdgeInsetsGeometry chatFieldPadding; - /// Icon shown wen trying to chose image attachment from camera - final Icon? imageAttachmentFromCameraIcon; + /// Margin around the main container. + final EdgeInsetsGeometry chatFieldMargin; - /// text shown wen trying to chose image attachment cancel text - final String imageAttachmentCancelText; + /// Decoration for the main container. + final Decoration decoration; - /// Icon shown wen trying to chose image attachment cancel text - final Icon? imageAttachmentCancelIcon; + /// Decoration for the TextField input. + final InputDecoration textFieldDecoration; - /// image attachment text style - final TextStyle? imageAttachmentTextStyle; + /// Customization for the wave animation widget. + final WaveAnimationStyle waveStyle; - /// the color of the outer container and the color used to hide - /// the text on slide - final Color chatInputFieldColor; + /// Customization for the recording/send button. + final RecordingButtonStyle buttonStyle; - // TODO right description - final TextDirection? textDirection; - final BoxDecoration? chatInputFieldDecoration; + /// Label for the “From Camera” option. + /// used for [ChatBottomSheet] + final String cameraText; - /// The callback when slider is completed. This is the only required field. - final VoidCallback onSlideToCancelRecord; - /// The callback when send is pressed. - final Function(ChatMessage text) onTextSubmit; + /// Icon for the “From Camera” option. + /// used for [ChatBottomSheet] + final Icon? cameraIcon; - /// function to handle the selected image - final Function(ChatMessage? imageMessage) handleImageSelect; + /// Label for the “From Gallery” option. + /// used for [ChatBottomSheet] + final String galleryText; - /// function to handle the recorded audio - final Function(ChatMessage? audioMessage, bool canceled) handleRecord; + /// Icon for the “From Gallery” option. + /// used for [ChatBottomSheet] + final Icon? galleryIcon; - final TextEditingController _textController = TextEditingController(); + /// Label for the “Cancel” option. + /// used for [ChatBottomSheet] + final String cancelText; - final EdgeInsets? chatInputFieldPadding; + /// Icon for the “Cancel” option. + /// used for [ChatBottomSheet] + final Icon? cancelIcon; - /// use this flag to disable the input - final bool disableInput; + /// Text style applied to all option labels. + /// used for [ChatBottomSheet] + final TextStyle? chatBottomSheetTextStyle; - /// this is an optional parameter to override the default attachment bottom sheet - final Function(BuildContext context)? attachmentClick; + final ValueChanged onTextSubmit; + /// + final bool enableInput; + final TextDirection? textDirection; + final String recordingNoteHintText; + + /// Creates a chat input field. ChatInputField({ Key? key, - this.buttonRadios = 35, - required this.sendMessageHintText, - required this.recordingNoteHintText, - this.textDirection, - this.chatInputFieldDecoration, - this.sliderButtonContent = const Icon( - Icons.chevron_right, - color: Colors.white, - size: 25, - ), - this.sendTextIcon = Icons.send, - required this.onSlideToCancelRecord, - required this.handleRecord, + required this.onRecordComplete, + required this.textController, + required this.onTextSubmit, - required this.handleImageSelect, - required this.chatInputFieldColor, - required this.imageAttachmentFromGalleryText, - required this.imageAttachmentFromCameraText, - required this.imageAttachmentCancelText, - required this.disableInput, - this.chatInputFieldPadding, - this.imageAttachmentFromGalleryIcon, - this.imageAttachmentFromCameraIcon, - this.imageAttachmentCancelIcon, - this.imageAttachmentTextStyle, - this.attachmentClick, - }); - - // : assert(height >= 25); + required this.onImageSelected, + this.cameraText = 'From Camera', + this.galleryText = 'From Gallery', + this.cancelText = 'Cancel', + this.cameraIcon, + this.galleryIcon, + this.cancelIcon, + this.chatBottomSheetTextStyle, + this.cancelThreshold = 100.0, + this.showWaveAnimation = true, + this.enableInput = true, + this.textDirection, + required this.recordingNoteHintText, + Duration? waveDuration, + EdgeInsetsGeometry? chatFieldPadding, + EdgeInsetsGeometry? chatFieldMargin, + Decoration? decoration, + InputDecoration? textFieldDecoration, + WaveAnimationStyle? waveStyle, + RecordingButtonStyle? buttonStyle, + }) : waveDuration = waveDuration ?? const Duration(milliseconds: 600), + chatFieldPadding = + chatFieldPadding ?? const EdgeInsets.symmetric(vertical: 6), + chatFieldMargin = + chatFieldMargin ?? const EdgeInsets.symmetric(horizontal: 6), + decoration = decoration ?? + BoxDecoration( + color: Colors.black12, borderRadius: BorderRadius.circular(26)), + textFieldDecoration = textFieldDecoration ?? + const InputDecoration.collapsed( + hintText: 'Type a message', + ), + waveStyle = waveStyle ?? const WaveAnimationStyle(), + buttonStyle = buttonStyle ?? const RecordingButtonStyle(), + super(key: key); + + @override + _ChatInputFieldState createState() => _ChatInputFieldState(); +} + +class _ChatInputFieldState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _waveController; + + @override + void initState() { + super.initState(); + _waveController = AnimationController( + vsync: this, + duration: widget.waveDuration, + )..repeat(); + } + + @override + void dispose() { + _waveController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { - final cancelPosition = MediaQuery.of(context).size.width * 0.95; - return ChangeNotifierProvider( - create: (context) => ChatInputFieldProvider( - onTextSubmit: onTextSubmit, - textController: _textController, - onSlideToCancelRecord: onSlideToCancelRecord, - cancelPosition: cancelPosition, - handleRecord: handleRecord, - handleImageSelect: handleImageSelect, + return ChangeNotifierProvider( + create: (_) => ChatInputProvider( + onImageSelected: widget.onImageSelected, + onTextSubmit: widget.onTextSubmit, + onRecordComplete: widget.onRecordComplete, + textController: widget.textController, + cancelThreshold: widget.cancelThreshold, ), - child: Consumer( - builder: (context, provider, child) { - return Padding( - padding: chatInputFieldPadding ?? EdgeInsets.only(bottom: 3), - child: IgnorePointer( - ignoring: disableInput, - child: Directionality( - textDirection: textDirection ?? TextDirection.rtl, - child: AnimatedContainer( - duration: Duration(milliseconds: provider.duration), - curve: Curves.ease, - width: double.infinity, - padding: EdgeInsets.all(3), - decoration: chatInputFieldDecoration, - child: Stack( - alignment: AlignmentDirectional.centerStart, - children: [ - _buildInputField(provider), - _buildDragTrail(provider), - _buildAnimatedButton(provider), - ], + child: Consumer( + builder: (context, provider, _) { + return Container( + padding: widget.chatFieldPadding, + margin: widget.chatFieldMargin, + decoration: widget.decoration, + child: Row( + children: [ + AnimatedPadding( + padding: + EdgeInsets.only(left: provider.isRecording ? 12 : 4), + duration: const Duration(milliseconds: 100)), + const SizedBox(width: 4), + Expanded( + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + child: provider.isRecording + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.delete, + ), + if (widget.showWaveAnimation) + Expanded( + child: WaveAnimation( + controller: _waveController, + style: widget.waveStyle, + ), + ), + Text( + '00:00', + // style: widget.waveStyle.timerTextStyle, + ), + SizedBox(width: 2), + Text( + widget.recordingNoteHintText, + // style: widget.waveStyle.timerTextStyle, + ), + const SizedBox(width: 12), + ], + ) + : Row( + children: [ + IconButton( + onPressed: () { + showModalBottomSheet( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.only( + topLeft: Radius.circular(10), + topRight: Radius.circular(10), + ), + ), + context: context, + builder: (BuildContext context) { + return ChatBottomSheet( + cameraText: widget.cameraText, + galleryText: widget.galleryText, + cancelText: widget.cancelText, + cameraIcon: widget.cameraIcon, + galleryIcon: widget.galleryIcon, + cancelIcon: widget.cancelIcon, + textStyle: + widget.chatBottomSheetTextStyle, + onCameraTap: () { + Navigator.pop(context); + + provider.pickImage( + ImageSourceType.camera); + }, + onGalleryTap: () { + provider.pickImage( + ImageSourceType.gallery); + }, + ); + }, + ); + }, + icon: Icon(Icons.add), + ), + Expanded( + child: TextField( + enabled: widget.enableInput, + controller: widget.textController, + decoration: widget.textFieldDecoration, + textDirection: widget.textDirection, + textCapitalization: + TextCapitalization.sentences, + ), + ), + ], + ), + ), ), - ), + SizedBox( + width: + provider.isRecording ? (20 - provider.dragOffset) : 5), + RecordingButton( + dragOffset: provider.dragOffset, + onLongPressStart: (_) => provider.startRecording(), + onLongPressMoveUpdate: (details) => + provider.onMove(details.offsetFromOrigin), + onLongPressEnd: (_) => provider.endRecording(), + onTap: provider.sendTextMessage, + hasText: provider.hasText, + style: widget.buttonStyle, + ), + ], ), ); }, ), ); } - - /// build Input Field widget - Widget _buildInputField(ChatInputFieldProvider provider) => - ChatInputFieldContainerWidget( - chatInputFieldColor: chatInputFieldColor, - isRecording: provider.isRecording, - recordingNoteHintText: recordingNoteHintText, - recordTime: provider.recordTime, - textController: _textController, - sendMessageHintText: sendMessageHintText, - onTextFieldValueChanged: provider.onTextFieldValueChanged, - attachmentClick: attachmentClick ?? - (context) { - _attachmentClick(context, provider); - }, - formKey: provider.formKey, - onSubmitted: provider.onAnimatedButtonTap); - - Widget _buildDragTrail(ChatInputFieldProvider provider) => ChatDragTrail( - cancelPosition: provider.getPosition(), - duration: provider.duration, - trailColor: chatInputFieldColor, - ); - - Widget _buildAnimatedButton(ChatInputFieldProvider provider) => - ChatAnimatedButton( - duration: provider.duration, - rightPosition: provider.getPosition(), - isRecording: provider.isRecording, - isText: provider.isText, - animatedButtonWidget: sliderButtonContent, - onAnimatedButtonTap: provider.onAnimatedButtonTap, - onAnimatedButtonLongPress: provider.onAnimatedButtonLongPress, - onAnimatedButtonLongPressMoveUpdate: - provider.onAnimatedButtonLongPressMoveUpdate, - onAnimatedButtonLongPressEnd: (details) => - provider.onAnimatedButtonLongPressEnd(details), - borderRadius: BorderRadius.all(Radius.circular(buttonRadios)), - sendTextIcon: sendTextIcon, - ); - - /// show a widget to choose picker type - - void _attachmentClick(BuildContext context, ChatInputFieldProvider provider) { - showModalBottomSheet( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.only( - topLeft: Radius.circular(10), - topRight: Radius.circular(10), - ), - ), - context: context, - builder: (BuildContext context) { - return ChatBottomSheet( - imageFromCameraOnTap: () { - Navigator.pop(context); - - provider.pickImage(1); - }, - imageFromGalleryOnTap: () { - Navigator.pop(context); - provider.pickImage(2); - }, - imageAttachmentFromCameraText: imageAttachmentFromCameraText, - imageAttachmentTextStyle: imageAttachmentTextStyle, - imageAttachmentFromGalleryText: imageAttachmentFromGalleryText, - imageAttachmentCancelText: imageAttachmentCancelText, - imageAttachmentFromGalleryIcon: imageAttachmentFromGalleryIcon, - imageAttachmentFromCameraIcon: imageAttachmentFromCameraIcon, - imageAttachmentCancelIcon: imageAttachmentCancelIcon, - ); - }, - ); - } } diff --git a/lib/components/chat_input_field/chat_input_field_provider.dart b/lib/components/chat_input_field/chat_input_field_provider.dart index 6f15a23..de04a25 100644 --- a/lib/components/chat_input_field/chat_input_field_provider.dart +++ b/lib/components/chat_input_field/chat_input_field_provider.dart @@ -1,243 +1,213 @@ -import 'dart:developer'; +import 'package:flutter/material.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:chat_package/models/chat_message.dart'; import 'package:chat_package/models/media/chat_media.dart'; import 'package:chat_package/models/media/media_type.dart'; -import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:permission_handler/permission_handler.dart'; -import 'package:record/record.dart'; -import 'package:stop_watch_timer/stop_watch_timer.dart'; -import 'package:path_provider/path_provider.dart'; -class ChatInputFieldProvider extends ChangeNotifier { - final Function(ChatMessage? audioMessage, bool cancel) handleRecord; - final VoidCallback onSlideToCancelRecord; +/// Defines the source for picking images: camera or gallery. +enum ImageSourceType { camera, gallery } - /// function to handle the selected image - final Function(ChatMessage? imageMessage) handleImageSelect; +/// Manages chat input logic: text entry, image picking, and audio recording gestures. +/// +/// - Tracks recording state and drag threshold for slide-to-cancel. +/// - Requests permissions, starts/stops audio recording. +/// - Emits callbacks for text submission, image selection, and audio recording. +class ChatInputProvider extends ChangeNotifier { + // ======== Callbacks ======== + /// Invoked with a [ChatMessage] when audio recording completes successfully. + final ValueChanged onRecordComplete; - /// The callback when send is pressed. - final Function(ChatMessage text) onTextSubmit; - final TextEditingController textController; - final double cancelPosition; + /// Invoked with a [ChatMessage] on text submission. + final ValueChanged onTextSubmit; - late AudioRecorder _record = AudioRecorder(); - double _position = 0; - int _duration = 0; - bool _isRecording = false; - int _recordTime = 0; - bool isText = false; - double _height = 70; - final StopWatchTimer _stopWatchTimer = StopWatchTimer(); - final _formKey = GlobalKey(); - - /// getters - int get duration => _duration; - bool get isRecording => _isRecording; - int get recordTime => _recordTime; - GlobalKey get formKey => _formKey; + /// Invoked with a [ChatMessage] when an image is picked, or `null` on cancel. + final ValueChanged onImageSelected; - /// setters - set height(double val) => _height = val; + // ======== Controllers & Config ======== + /// Controller for the text input field. + final TextEditingController textController; - Permission micPermission = Permission.microphone; - ChatInputFieldProvider({ + /// Horizontal drag distance threshold to cancel recording (in pixels). + final double cancelThreshold; + + // ======== Internal State ======== + final _audioRecorder = AudioRecorder(); + bool _isRecording = false; + double _dragOffset = 0.0; + + /// Constructs a [ChatInputProvider]. + /// + /// - [onRecordComplete]: callback when audio recording is done. + /// - [onTextSubmit]: callback for text messages. + /// - [onImageSelected]: callback for image selection or cancellation. + /// - [textController]: controller for text input. + /// - [cancelThreshold]: pixels user must drag to cancel recording. + ChatInputProvider({ + required this.onRecordComplete, required this.onTextSubmit, + required this.onImageSelected, required this.textController, - required this.handleRecord, - required this.onSlideToCancelRecord, - required this.cancelPosition, - required this.handleImageSelect, - }); - - /// animated button on tap - void onAnimatedButtonTap() { - _formKey.currentState?.save(); - if (isText && textController.text.isNotEmpty) { - final textMessage = - ChatMessage(isSender: true, text: textController.text); - onTextSubmit(textMessage); - } - textController.clear(); - isText = false; - notifyListeners(); + required this.cancelThreshold, + }) { + textController.addListener(_onTextChanged); } - /// animated button on LongPress - void onAnimatedButtonLongPress() async { - // HapticFeedback.heavyImpact(); - final permissionStatus = await micPermission.request(); + /// Whether the provider is currently recording audio. + bool get isRecording => _isRecording; - if (permissionStatus.isGranted) { - if (!isText) { - _stopWatchTimer.onStartTimer(); - _stopWatchTimer.rawTime.listen((value) { - _recordTime = value; + /// Current drag offset along the X axis (negative when dragging left). + double get dragOffset => _dragOffset; - print('rawTime $value ${StopWatchTimer.getDisplayTime(_recordTime)}'); - notifyListeners(); - }); + /// True if the text input contains non-whitespace characters. + bool get hasText => textController.text.trim().isNotEmpty; - textController.clear(); - recordAudio(); + // ───────────────────────────────────────────────────────────────────────── + // Permission & Recording Helpers + // ───────────────────────────────────────────────────────────────────────── - _isRecording = true; - notifyListeners(); - } - } - if (permissionStatus.isPermanentlyDenied) { - openAppSettings(); + /// Requests [perm] and opens app settings if permanently denied. + Future _requestPermission(Permission perm) async { + final status = await perm.request(); + if (status.isPermanentlyDenied) { + await openAppSettings(); + return false; } + return status.isGranted; } - /// animated button on Long Press Move Update - void onAnimatedButtonLongPressMoveUpdate( - LongPressMoveUpdateDetails details) async { - if (!isText && _isRecording == true) { - _duration = 0; - _position = details.localPosition.dx * -1; - notifyListeners(); + /// Starts recording to a timestamped `.m4a` file in the temp directory. + Future _startAudioRecord() async { + final dir = await getTemporaryDirectory(); + final filePath = '${dir.path}/${DateTime.now().millisecondsSinceEpoch}.m4a'; + if (await _audioRecorder.isRecording()) { + await _audioRecorder.stop(); } + await _audioRecorder.start( + const RecordConfig(), + path: filePath, + ); } - /// animated button on Long Press End - void onAnimatedButtonLongPressEnd(LongPressEndDetails details) async { - final source = await stopRecord(); - // Stop - _stopWatchTimer.onStopTimer(); - - // Reset - _stopWatchTimer.onResetTimer(); - - if (!isText && await micPermission.isGranted) { - if (_position > cancelPosition - _height || source == null) { - log('canceled'); - - handleRecord(null, true); - - onSlideToCancelRecord(); - } else { - final audioMessage = ChatMessage( - isSender: true, - chatMedia: ChatMedia( - url: source, - mediaType: MediaType.audioMediaType(), - ), - ); - handleRecord(audioMessage, false); - } - - _duration = 600; - _position = 0; - _isRecording = false; - notifyListeners(); - } - } + /// Stops recording and returns the recorded file path, or `null`. + Future _stopAudioRecord() => _audioRecorder.stop(); + + // ───────────────────────────────────────────────────────────────────────── + // Recording Gesture Handlers + // ───────────────────────────────────────────────────────────────────────── - /// function used to record audio - void recordAudio() async { - final tempDir = await getTemporaryDirectory(); - final path = '${tempDir.path}/${DateTime.now().millisecondsSinceEpoch}.m4a'; + /// Initiates audio recording on long-press start if no text is present. + Future startRecording() async { + if (hasText) return; + if (!await _requestPermission(Permission.microphone)) return; - if (await _record.isRecording()) { - _record.stop(); + await _startAudioRecord(); + _isRecording = true; + _dragOffset = 0.0; + notifyListeners(); + } + + /// Updates drag offset. Cancels recording if threshold is exceeded. + void onMove(Offset offset) { + if (!_isRecording || hasText) return; + _dragOffset = offset.dx.clamp(-cancelThreshold, 0.0); + if (_dragOffset <= -cancelThreshold) { + _resetRecordingState(); } - await _record.start(const RecordConfig(), path: path); + notifyListeners(); } - /// function used to stop recording - /// and returns the record path as a string + /// Ends recording on long-press release; completes or cancels accordingly. + Future endRecording() async { + if (!_isRecording) return; - Future stopRecord() async { - return await _record.stop(); - } + final recordedPath = await _stopAudioRecord(); + final canceled = + (recordedPath == null) || (_dragOffset <= -cancelThreshold); - /// get the animated button position - double getPosition() { - log(_position.toString()); - if (_position < 0) { - return 0; - } else if (_position > cancelPosition - _height) { - return cancelPosition - _height; - } else { - return _position; + _resetRecordingState(); + if (!canceled) { + final message = ChatMessage( + text: '', + isSender: true, + chatMedia: ChatMedia( + url: recordedPath, + mediaType: MediaType.audioMediaType(), + ), + ); + onRecordComplete(message); } + notifyListeners(); } - // TODO: make this custom from user - /// open image picker from camera, gallery, or cancel the selection - void pickImage(int type) async { - final cameraPermission = Permission.camera; - final storagePermission = Permission.camera; - if (type == 1) { - final permissionStatus = await cameraPermission.request(); - if (permissionStatus.isGranted) { - final path = await _getImagePathFromSource(1); - final imageMessage = _getImageMEssageFromPath(path); - handleImageSelect(imageMessage); - return; - } else { - handleImageSelect(null); - return; - } - } else { - final permissionStatus = await storagePermission.request(); - if (permissionStatus.isGranted) { - final path = await _getImagePathFromSource(2); - final imageMessage = _getImageMEssageFromPath(path); - handleImageSelect(imageMessage); - return; - } else { - handleImageSelect(null); - return; - } - } + /// Sends the current text message if non-empty, then clears the input. + void sendTextMessage() { + if (!hasText) return; + final message = ChatMessage( + text: textController.text, + isSender: true, + ); + onTextSubmit(message); + + textController.clear(); + notifyListeners(); } - Future _getImagePathFromSource(int type) async { - final result = await ImagePicker().pickImage( + // ───────────────────────────────────────────────────────────────────────── + // Image Picking + // ───────────────────────────────────────────────────────────────────────── + + /// Picks an image from [sourceType], then invokes [onImageSelected]. + Future pickImage(ImageSourceType sourceType) async { + final permission = sourceType == ImageSourceType.camera + ? Permission.camera + : Permission.photos; + if (!await _requestPermission(permission)) { + return; + } + + final picker = ImagePicker(); + final source = sourceType == ImageSourceType.camera + ? ImageSource.camera + : ImageSource.gallery; + final file = await picker.pickImage( + source: source, imageQuality: 70, maxWidth: 1440, - source: type == 1 ? ImageSource.camera : ImageSource.gallery, ); - return result?.path; - } - ChatMessage? _getImageMEssageFromPath(String? path) { - if (path != null) { - final imageMessage = ChatMessage( + if (file == null) { + } else { + final message = ChatMessage( + text: '', isSender: true, chatMedia: ChatMedia( - url: path, + url: file.path, mediaType: MediaType.imageMediaType(), ), ); - return imageMessage; - } else { - return null; + onImageSelected(message); } } - void onTextFieldValueChanged(String value) { - if (value.length > 0) { - textController.text = value; - isText = true; - notifyListeners(); - } else { - isText = false; - notifyListeners(); - } - } + // ───────────────────────────────────────────────────────────────────────── + // Internal Helpers + // ───────────────────────────────────────────────────────────────────────── - Future getAppTempDirectoryPath() async { - final dir = await getTemporaryDirectory(); - return dir.path; + void _onTextChanged() => notifyListeners(); + + void _resetRecordingState() { + _isRecording = false; + _dragOffset = 0.0; } @override void dispose() { - textController.dispose(); + textController.removeListener(_onTextChanged); + _audioRecorder.dispose(); super.dispose(); } } diff --git a/lib/components/chat_input_field/widgets/chat_animated_button.dart b/lib/components/chat_input_field/widgets/chat_animated_button.dart deleted file mode 100644 index 7b70a10..0000000 --- a/lib/components/chat_input_field/widgets/chat_animated_button.dart +++ /dev/null @@ -1,68 +0,0 @@ -import 'package:chat_package/utils/constants.dart'; -import 'package:flutter/material.dart'; - -class ChatAnimatedButton extends StatelessWidget { - final int duration; - final rightPosition; - final bool isRecording; - final bool isText; - final Widget? animatedButtonWidget; - final Function() onAnimatedButtonTap; - final Function() onAnimatedButtonLongPress; - final Function(LongPressMoveUpdateDetails) - onAnimatedButtonLongPressMoveUpdate; - final Function(LongPressEndDetails details) onAnimatedButtonLongPressEnd; - final BorderRadiusGeometry? borderRadius; - final IconData sendTextIcon; - //TODO SHould add button shape - - const ChatAnimatedButton( - {super.key, - required this.duration, - this.rightPosition, - required this.isRecording, - required this.isText, - this.animatedButtonWidget, - required this.onAnimatedButtonTap, - required this.onAnimatedButtonLongPress, - required this.onAnimatedButtonLongPressMoveUpdate, - required this.onAnimatedButtonLongPressEnd, - this.borderRadius, - required this.sendTextIcon}); - - @override - Widget build(BuildContext context) { - return AnimatedPositioned( - duration: Duration(milliseconds: duration), - curve: Curves.bounceOut, - right: rightPosition, - // top: 0, - bottom: 0, - child: GestureDetector( - onTap: onAnimatedButtonTap, - onLongPress: onAnimatedButtonLongPress, - onLongPressMoveUpdate: onAnimatedButtonLongPressMoveUpdate, - onLongPressEnd: onAnimatedButtonLongPressEnd, - child: AnimatedSize( - curve: Curves.easeIn, - duration: Duration(milliseconds: 100), - child: Container( - height: 50, - width: 50, - decoration: BoxDecoration( - borderRadius: borderRadius, - color: kSecondaryColor, - ), - child: isRecording - ? animatedButtonWidget - : Icon( - isText ? sendTextIcon : Icons.mic, - color: Colors.white, - size: 25, - ), - ), - ), - ), - ); - } -} diff --git a/lib/components/chat_input_field/widgets/chat_attachment_bottom_sheet.dart b/lib/components/chat_input_field/widgets/chat_attachment_bottom_sheet.dart deleted file mode 100644 index ad0e357..0000000 --- a/lib/components/chat_input_field/widgets/chat_attachment_bottom_sheet.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; - -class ChatBottomSheet extends StatelessWidget { - final Function() imageFromCameraOnTap; - - final Function() imageFromGalleryOnTap; - //TODO: add disable functionality - final String imageAttachmentFromCameraText; - final Icon? imageAttachmentFromCameraIcon; - - final String imageAttachmentFromGalleryText; - final Icon? imageAttachmentFromGalleryIcon; - - final String imageAttachmentCancelText; - final Icon? imageAttachmentCancelIcon; - - final TextStyle? imageAttachmentTextStyle; - const ChatBottomSheet({ - super.key, - required this.imageFromCameraOnTap, - required this.imageFromGalleryOnTap, - required this.imageAttachmentFromCameraText, - this.imageAttachmentTextStyle, - required this.imageAttachmentFromGalleryText, - required this.imageAttachmentCancelText, - this.imageAttachmentFromCameraIcon, - this.imageAttachmentFromGalleryIcon, - this.imageAttachmentCancelIcon, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(10), - child: Wrap( - children: [ - ListTile( - leading: imageAttachmentFromCameraIcon ?? Icon(Icons.camera), - title: Text( - imageAttachmentFromCameraText, - style: imageAttachmentTextStyle, - ), - onTap: imageFromCameraOnTap, - ), - ListTile( - leading: imageAttachmentFromGalleryIcon ?? Icon(Icons.image), - title: Text( - imageAttachmentFromGalleryText, - style: imageAttachmentTextStyle, - ), - onTap: imageFromGalleryOnTap, - ), - ListTile( - leading: imageAttachmentCancelIcon ?? Icon(Icons.cancel), - title: Text( - imageAttachmentCancelText, - style: imageAttachmentTextStyle, - ), - onTap: () => Navigator.pop(context), - ), - ], - ), - ); - } -} diff --git a/lib/components/chat_input_field/widgets/chat_bottom_sheet.dart b/lib/components/chat_input_field/widgets/chat_bottom_sheet.dart new file mode 100644 index 0000000..0f02ac1 --- /dev/null +++ b/lib/components/chat_input_field/widgets/chat_bottom_sheet.dart @@ -0,0 +1,99 @@ +import 'package:flutter/material.dart'; + +/// A bottom sheet presenting image attachment options. +/// +/// Displays three tappable options: +/// 1. Capture a new photo via camera +/// 2. Pick an existing image from the gallery +/// 3. Cancel and dismiss the sheet +/// +/// All texts, icons, and styles are customizable. +class ChatBottomSheet extends StatelessWidget { + /// Called when the user taps “From Camera”. + final VoidCallback onCameraTap; + + /// Called when the user taps “From Gallery”. + final VoidCallback onGalleryTap; + + /// Label for the “From Camera” option. + final String cameraText; + + /// Icon for the “From Camera” option. + final Icon? cameraIcon; + + /// Label for the “From Gallery” option. + final String galleryText; + + /// Icon for the “From Gallery” option. + final Icon? galleryIcon; + + /// Label for the “Cancel” option. + final String cancelText; + + /// Icon for the “Cancel” option. + final Icon? cancelIcon; + + /// Text style applied to all option labels. + final TextStyle? textStyle; + + /// Creates a chat attachment bottom sheet. + /// + /// [onCameraTap] and [onGalleryTap] must not be null. + /// [cameraText], [galleryText], and [cancelText] default to + /// "From Camera", "From Gallery", and "Cancel" respectively. + const ChatBottomSheet({ + Key? key, + required this.onCameraTap, + required this.onGalleryTap, + this.cameraText = 'From Camera', + this.galleryText = 'From Gallery', + this.cancelText = 'Cancel', + this.cameraIcon, + this.galleryIcon, + this.cancelIcon, + this.textStyle, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16), + child: Wrap( + children: [ + _buildOption( + context: context, + icon: cameraIcon ?? const Icon(Icons.camera_alt), + label: cameraText, + onTap: onCameraTap, + ), + _buildOption( + context: context, + icon: galleryIcon ?? const Icon(Icons.photo_library), + label: galleryText, + onTap: onGalleryTap, + ), + _buildOption( + context: context, + icon: cancelIcon ?? const Icon(Icons.close), + label: cancelText, + onTap: () => Navigator.of(context).pop, + ), + ], + ), + ); + } + + /// Helper to build each list tile option. + Widget _buildOption({ + required BuildContext context, + required Icon icon, + required String label, + required VoidCallback onTap, + }) { + return ListTile( + leading: icon, + title: Text(label, style: textStyle), + onTap: onTap, + ); + } +} diff --git a/lib/components/chat_input_field/widgets/chat_drag_trail.dart b/lib/components/chat_input_field/widgets/chat_drag_trail.dart deleted file mode 100644 index b4cdc63..0000000 --- a/lib/components/chat_input_field/widgets/chat_drag_trail.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:flutter/material.dart'; - -class ChatDragTrail extends StatelessWidget { - final double cancelPosition; - final int duration; - final Color trailColor; - const ChatDragTrail( - {super.key, - required this.cancelPosition, - required this.duration, - required this.trailColor}); - - @override - Widget build(BuildContext context) { - return AnimatedContainer( - height: 50, - width: cancelPosition, - duration: Duration(milliseconds: duration), - curve: Curves.ease, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(40), - color: trailColor, - ), - ); - } -} diff --git a/lib/components/chat_input_field/widgets/recording_button.dart b/lib/components/chat_input_field/widgets/recording_button.dart new file mode 100644 index 0000000..0bd27e3 --- /dev/null +++ b/lib/components/chat_input_field/widgets/recording_button.dart @@ -0,0 +1,114 @@ +import 'package:flutter/material.dart'; + +/// Style configuration for [RecordingButton]. +class RecordingButtonStyle { + /// Background color of the button. + final Color buttonColor; + + /// Color of the icon. + final Color iconColor; + + /// Size of the icon. + final double iconSize; + + /// Inner padding around the icon. + final EdgeInsets padding; + + /// Icon to show when [hasText] is true. + final IconData sendIcon; + + /// Icon to show when [hasText] is false. + final IconData micIcon; + + /// Optional decoration for the button container (overrides [buttonColor] & shape). + final BoxDecoration? decoration; + + /// Duration of the icon switch animation. + final Duration switchDuration; + + /// Animation curve for the icon switch. + final Curve switchCurve; + + const RecordingButtonStyle({ + this.buttonColor = const Color(0xFF075E54), + this.iconColor = Colors.white, + this.iconSize = 24.0, + this.padding = const EdgeInsets.all(12), + this.sendIcon = Icons.send, + this.micIcon = Icons.mic, + this.decoration, + this.switchDuration = const Duration(milliseconds: 200), + this.switchCurve = Curves.easeInOut, + }); +} + +/// A customizable recording/send button with drag‑offset support. +/// +/// - Shows a mic icon when [hasText] is false, or a send icon when true. +/// - Animates icon changes via an [AnimatedSwitcher]. +/// - Applies a horizontal [dragOffset] translation. +/// - Exposes full styling via [style]. +class RecordingButton extends StatelessWidget { + /// Horizontal drag offset (negative moves left). + final double dragOffset; + + /// Called on long‑press start (begin recording). + final GestureLongPressStartCallback onLongPressStart; + + /// Called as the finger moves during a long press. + final GestureLongPressMoveUpdateCallback? onLongPressMoveUpdate; + + /// Called on long‑press end (stop recording). + final GestureLongPressEndCallback? onLongPressEnd; + + /// Called on normal tap (send text). + final VoidCallback onTap; + + /// Whether there’s text to send (switches to send icon). + final bool hasText; + + /// Styling parameters. + final RecordingButtonStyle style; + + const RecordingButton({ + Key? key, + required this.dragOffset, + required this.onLongPressStart, + this.onLongPressMoveUpdate, + this.onLongPressEnd, + required this.onTap, + required this.hasText, + this.style = const RecordingButtonStyle(), + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final deco = style.decoration ?? + BoxDecoration(color: style.buttonColor, shape: BoxShape.circle); + + return Transform.translate( + offset: Offset(dragOffset, 0), + child: GestureDetector( + onLongPressStart: onLongPressStart, + onLongPressMoveUpdate: onLongPressMoveUpdate, + onLongPressEnd: onLongPressEnd, + onTap: onTap, + child: AnimatedSwitcher( + duration: style.switchDuration, + switchInCurve: style.switchCurve, + switchOutCurve: style.switchCurve, + child: Container( + key: ValueKey(hasText), + decoration: deco, + padding: style.padding, + child: Icon( + hasText ? style.sendIcon : style.micIcon, + color: style.iconColor, + size: style.iconSize, + ), + ), + ), + ), + ); + } +} diff --git a/lib/components/chat_input_field/widgets/wave_animation.dart b/lib/components/chat_input_field/widgets/wave_animation.dart new file mode 100644 index 0000000..213066a --- /dev/null +++ b/lib/components/chat_input_field/widgets/wave_animation.dart @@ -0,0 +1,86 @@ +import 'dart:math'; +import 'dart:ui'; +import 'package:flutter/material.dart'; + +/// Styling configuration for [WaveAnimation]. +class WaveAnimationStyle { + /// How many bars to draw. + final int barCount; + + /// Color of each bar. + final Color barColor; + + /// Width of each bar. + final double barWidth; + + /// Spacing between bars. + final double barSpacing; + + /// Minimum bar height. + final double minBarHeight; + + /// Maximum bar height. + final double maxBarHeight; + + /// Border radius for each bar. + final BorderRadius barBorderRadius; + + const WaveAnimationStyle({ + this.barCount = 5, + this.barColor = const Color(0xFF075E54), + this.barWidth = 4.0, + this.barSpacing = 2.0, + this.minBarHeight = 8.0, + this.maxBarHeight = 24.0, + this.barBorderRadius = const BorderRadius.all(Radius.circular(2)), + }); +} + +/// A bar‑wave animation driven by the given [controller]. +/// +/// The bars oscillate vertically between [style.minBarHeight] and +/// [style.maxBarHeight], phased evenly across [style.barCount]. +class WaveAnimation extends StatelessWidget { + /// The animation controller (from 0.0–1.0) that drives the wave. + final Animation controller; + + /// Visual styling for the wave. + final WaveAnimationStyle style; + + const WaveAnimation({ + Key? key, + required this.controller, + this.style = const WaveAnimationStyle(), + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, child) { + final t = controller.value * 2 * pi; + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(style.barCount, (i) { + final phase = t + i * (2 * pi / style.barCount); + final height = lerpDouble( + style.minBarHeight, + style.maxBarHeight, + (sin(phase) + 1) / 2, + )!; + return Container( + width: style.barWidth, + height: height, + margin: EdgeInsets.symmetric(horizontal: style.barSpacing), + decoration: BoxDecoration( + color: style.barColor, + borderRadius: style.barBorderRadius, + ), + ); + }), + ); + }, + ); + } +} diff --git a/lib/components/message/audio_message/audio_message_widget.dart b/lib/components/message/audio_message/audio_message_widget.dart index 375019a..6677057 100644 --- a/lib/components/message/audio_message/audio_message_widget.dart +++ b/lib/components/message/audio_message/audio_message_widget.dart @@ -1,144 +1,144 @@ -import 'package:chat_package/models/chat_message.dart'; -import 'package:chat_package/utils/constants.dart'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; +import 'package:chat_package/models/chat_message.dart'; +import 'package:chat_package/utils/constants.dart'; -/// this widget is used to render voice note container -/// with ist full functionality - +/// Renders an audio message bubble with play/pause, seek bar, and timer. class AudioMessageWidget extends StatefulWidget { final ChatMessage message; final Color senderColor; - final Color inActiveAudioSliderColor; + final Color inactiveAudioSliderColor; final Color activeAudioSliderColor; - AudioMessageWidget( - {Key? key, - required this.message, - required this.senderColor, - required this.inActiveAudioSliderColor, - required this.activeAudioSliderColor}) - : super(key: key); + const AudioMessageWidget({ + Key? key, + required this.message, + required this.senderColor, + required this.inactiveAudioSliderColor, + required this.activeAudioSliderColor, + }) : super(key: key); @override _AudioMessageWidgetState createState() => _AudioMessageWidgetState(); } class _AudioMessageWidgetState extends State { - final player = AudioPlayer(); - Duration? duration = Duration.zero; - Duration seekBarPosition = Duration.zero; - bool isPlaying = false; + late final AudioPlayer _player; + late final Future _initFuture; @override void initState() { - setData(); super.initState(); + _player = AudioPlayer(); + _initFuture = _loadAudio(); } - void setData() async { - Uri.parse(widget.message.chatMedia!.url).isAbsolute - ? duration = await player.setUrl(widget.message.chatMedia!.url) - : duration = await player.setFilePath(widget.message.chatMedia!.url); + Future _loadAudio() { + final url = widget.message.chatMedia!.url; + if (Uri.tryParse(url)?.isAbsolute ?? false) { + return _player.setUrl(url); + } + return _player.setFilePath(url); + } - setState(() {}); + @override + void dispose() { + _player.dispose(); + super.dispose(); + } + + String _format(Duration? d) { + if (d == null) return '00:00'; + final two = (int n) => n.toString().padLeft(2, '0'); + final m = two(d.inMinutes.remainder(60)); + final s = two(d.inSeconds.remainder(60)); + return d.inHours > 0 ? '${two(d.inHours)}:$m:$s' : '$m:$s'; } @override Widget build(BuildContext context) { - return Column( - crossAxisAlignment: widget.message.isSender - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, - children: [ - Container( - width: MediaQuery.of(context).size.width * 0.7, - padding: EdgeInsets.symmetric( - horizontal: kDefaultPadding * 0.75, - ), + final isSender = widget.message.isSender; + final bubbleColor = widget.senderColor.withOpacity(isSender ? 1 : 0.1); + final iconColor = isSender ? Colors.white : widget.senderColor; + + return Align( + alignment: isSender ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.7, + ), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: kDefaultPadding), decoration: BoxDecoration( + color: bubbleColor, borderRadius: BorderRadius.circular(30), - color: (widget.senderColor) - .withOpacity(widget.message.isSender ? 1 : 0.1), ), - child: Row( - /// mainAxisAlignment: MainAxisAlignment.end, - children: [ - IconButton( - onPressed: () { - isPlaying ? player.pause() : play(); - setState(() { - isPlaying = !isPlaying; - }); - }, - icon: Icon( - isPlaying ? Icons.pause : Icons.play_arrow, - color: widget.message.isSender - ? Colors.white - : (widget.senderColor), - // size: 25, - ), - ), - Expanded( - child: Slider( - activeColor: widget.activeAudioSliderColor, - inactiveColor: widget.inActiveAudioSliderColor, - max: player.duration?.inMilliseconds.toDouble() ?? 1, - value: player.position.inMilliseconds.toDouble(), - onChanged: (d) { - setState(() { - player.seek(Duration(milliseconds: d.toInt())); - }); - }), - ), - Text( - _printDuration(player.position), - style: TextStyle( - fontSize: 12, - color: widget.message.isSender ? Colors.white : null), - ), - ], + child: FutureBuilder( + future: _initFuture, + builder: (context, snap) { + if (snap.connectionState != ConnectionState.done) { + return const SizedBox( + height: 50, + child: Center(child: CircularProgressIndicator()), + ); + } + return Row( + children: [ + // Play/Pause button + StreamBuilder( + stream: _player.playingStream, + initialData: false, + builder: (context, playSnap) { + final playing = playSnap.data!; + return IconButton( + icon: Icon(playing ? Icons.pause : Icons.play_arrow), + color: iconColor, + onPressed: () => + playing ? _player.pause() : _player.play(), + ); + }, + ), + + // Seek bar + Expanded( + child: StreamBuilder( + stream: _player.positionStream, + initialData: Duration.zero, + builder: (context, posSnap) { + final pos = posSnap.data!; + final total = _player.duration ?? snap.data!; + return Slider( + min: 0, + max: total.inMilliseconds.toDouble(), + value: pos.inMilliseconds + .clamp(0.0, total.inMilliseconds.toDouble()) + .toDouble(), + activeColor: widget.activeAudioSliderColor, + inactiveColor: widget.inactiveAudioSliderColor, + onChanged: (ms) => + _player.seek(Duration(milliseconds: ms.toInt())), + ); + }, + ), + ), + + // Elapsed time + StreamBuilder( + stream: _player.positionStream, + initialData: Duration.zero, + builder: (context, posSnap) { + return Text( + _format(posSnap.data), + style: TextStyle(fontSize: 12, color: iconColor), + ); + }, + ), + ], + ); + }, ), ), - ], + ), ); } - - /// this function is used to play audio wither its from url or path file - void play() { - if (player.duration != null && player.position >= player.duration!) { - player.seek(Duration.zero); - setState(() { - isPlaying = false; - }); - } - print(player.duration); - print(player.position); - player.play(); - - player.positionStream.listen((duration) { - // duration == player.duration; - setState(() { - seekBarPosition = duration; - }); - if (player.duration != null && player.position >= player.duration!) { - player.stop(); - player.seek(Duration.zero); - setState(() { - isPlaying = false; - seekBarPosition = Duration.zero; - }); - } - }); - } - - /// function used to print the duration of the current audio duration - String _printDuration(Duration duration) { - String twoDigits(int n) => n.toString().padLeft(2, "0"); - String twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60)); - String twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60)); - String hoursString = - duration.inHours == 0 ? '' : "${twoDigits(duration.inHours)}:"; - return "$hoursString$twoDigitMinutes:$twoDigitSeconds"; - } } diff --git a/lib/components/message/date_time_widget.dart b/lib/components/message/date_time_widget.dart index b0ed07f..4629592 100644 --- a/lib/components/message/date_time_widget.dart +++ b/lib/components/message/date_time_widget.dart @@ -2,27 +2,47 @@ import 'package:chat_package/models/chat_message.dart'; import 'package:chat_package/utils/constants.dart'; import 'package:flutter/material.dart'; +/// A widget that displays the timestamp for a chat message. +/// +// — Formats [message.createdAt] into a user-friendly string and applies +/// padding and text styling. class DateTimeWidget extends StatelessWidget { + /// The chat message containing the creation timestamp. + final ChatMessage message; + + /// Optional style for the timestamp text. final TextStyle? sendDateTextStyle; + + /// Creates a [DateTimeWidget]. + /// + /// - [message]: the chat message whose timestamp will be displayed. + /// - [sendDateTextStyle]: custom text style for the timestamp. const DateTimeWidget({ Key? key, required this.message, this.sendDateTextStyle, }) : super(key: key); - final ChatMessage message; - @override Widget build(BuildContext context) { + final formattedDate = dateStringFormatter( + message.createdAt ?? DateTime.now(), + ); + + // Use bodySmall in place of the removed caption style + final defaultStyle = Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontSize: 12, color: Colors.grey) ?? + const TextStyle(fontSize: 12, color: Colors.grey); + return Padding( - padding: EdgeInsets.only( - // top: 2, - left: kDefaultPadding / 2, - right: kDefaultPadding / 2, + padding: const EdgeInsets.symmetric( + horizontal: kDefaultPadding / 2, ), child: Text( - dateStringFormatter(message.createdAt ?? DateTime.now()), - style: sendDateTextStyle ?? TextStyle(fontSize: 12), + formattedDate, + style: sendDateTextStyle ?? defaultStyle, ), ); } diff --git a/lib/components/message/message_widget.dart b/lib/components/message/message_widget.dart index 899852d..47b74c2 100644 --- a/lib/components/message/message_widget.dart +++ b/lib/components/message/message_widget.dart @@ -1,84 +1,110 @@ +import 'package:flutter/material.dart'; import 'package:chat_package/components/message/date_time_widget.dart'; -import 'package:chat_package/models/chat_message.dart'; -import 'package:chat_package/utils/constants.dart'; import 'package:chat_package/components/message/audio_message/audio_message_widget.dart'; import 'package:chat_package/components/message/image_message/image_message_widget.dart'; import 'package:chat_package/components/message/text_message/text_message_widget.dart'; -import 'package:flutter/material.dart'; +import 'package:chat_package/models/chat_message.dart'; +import 'package:chat_package/utils/constants.dart'; -/// widget used to determine the right message type -/// //TODO add color support for reciver +/// A chat bubble that renders text, image, audio (or future video) messages, +/// along with a timestamp. +/// +/// Aligns to the right for sender messages and to the left for receiver messages, +/// applying distinct bubble colors and styles. class MessageWidget extends StatelessWidget { + /// Data for this chat message (text, media, timestamp, sender flag). + final ChatMessage message; + + /// Bubble color for messages sent by the user. final Color senderColor; - final Color inActiveAudioSliderColor; + + /// Bubble color for messages received from others. + final Color receiverColor; + + /// Inactive track color for audio message slider. + final Color inactiveAudioSliderColor; + + /// Active track color for audio message slider. final Color activeAudioSliderColor; + + /// Optional text style applied inside image message containers. final TextStyle? messageContainerTextStyle; + + /// Optional style for the timestamp text. final TextStyle? sendDateTextStyle; + /// Creates a [MessageWidget]. + /// + /// All color parameters are required to ensure consistent theming. const MessageWidget({ Key? key, required this.message, required this.senderColor, - required this.inActiveAudioSliderColor, + required this.receiverColor, + required this.inactiveAudioSliderColor, required this.activeAudioSliderColor, this.messageContainerTextStyle, this.sendDateTextStyle, }) : super(key: key); - final ChatMessage message; - @override Widget build(BuildContext context) { + final bool isSender = message.isSender; + final Alignment alignment = + isSender ? Alignment.centerRight : Alignment.centerLeft; + final CrossAxisAlignment crossAxisAlignment = + isSender ? CrossAxisAlignment.end : CrossAxisAlignment.start; + final Color bubbleColor = isSender ? senderColor : receiverColor; + return Padding( padding: const EdgeInsets.only(top: kDefaultPadding), child: Align( - alignment: - message.isSender ? Alignment.centerRight : Alignment.centerLeft, - - /// check message type and render the right widget + alignment: alignment, child: Column( - crossAxisAlignment: message.isSender - ? CrossAxisAlignment.end - : CrossAxisAlignment.start, + crossAxisAlignment: crossAxisAlignment, children: [ - messageContent(message), - SizedBox( - height: 3, - ), + _buildContent(bubbleColor), + const SizedBox(height: 3), DateTimeWidget( message: message, sendDateTextStyle: sendDateTextStyle, - ) + ), ], ), ), ); } - Widget messageContent(ChatMessage message) { - /// check message type and render the right widget + /// Chooses and returns the correct content widget based on [message]. + /// + /// - Text messages render with [TextMessageWidget]. + /// - Image messages use [ImageMessageWidget]. + /// - Audio messages use [AudioMessageWidget]. + /// - Video messages are not yet implemented. + Widget _buildContent(Color bubbleColor) { if (message.chatMedia == null) { - /// render text message return TextMessageWidget( message: message, - senderColor: senderColor, - ); - } else { - return message.chatMedia!.mediaType.when( - imageMediaType: () => ImageMessageWidget( - message: message, - senderColor: senderColor, - messageContainerTextStyle: messageContainerTextStyle, - ), - audioMediaType: () => AudioMessageWidget( - message: message, - senderColor: senderColor, - activeAudioSliderColor: activeAudioSliderColor, - inActiveAudioSliderColor: inActiveAudioSliderColor, - ), - //TODO add this - videoMediaType: () => Container(), + senderColor: bubbleColor, ); } + + return message.chatMedia!.mediaType.when( + imageMediaType: () => ImageMessageWidget( + message: message, + senderColor: bubbleColor, + messageContainerTextStyle: messageContainerTextStyle, + ), + audioMediaType: () => AudioMessageWidget( + message: message, + senderColor: bubbleColor, + inactiveAudioSliderColor: inactiveAudioSliderColor, + activeAudioSliderColor: activeAudioSliderColor, + ), + videoMediaType: () { + // TODO: Replace with VideoMessageWidget when available + return const SizedBox.shrink(); + }, + ); } } diff --git a/lib/models/chat_message.dart b/lib/models/chat_message.dart index 90edce9..4411eb5 100644 --- a/lib/models/chat_message.dart +++ b/lib/models/chat_message.dart @@ -1,21 +1,49 @@ import 'dart:convert'; - +import 'package:flutter/foundation.dart'; import 'package:chat_package/models/media/chat_media.dart'; +@immutable class ChatMessage { - /// please note that only one of the following [text,imageUrl,imagePath,audioUrl,audioPath ] - ///must not be null at a time if more is provided an error will occur - String text; + // JSON keys + static const _keyText = 'text'; + static const _keyChatMedia = 'chatMedia'; + static const _keyIsSender = 'isSender'; + static const _keyCreatedAt = 'createdAt'; + + /// The textual content of the message. + /// Mutually exclusive with [chatMedia]. + final String text; + + /// The media content of the message. + /// Mutually exclusive with [text]. final ChatMedia? chatMedia; + + /// Whether this message was sent by the local user. final bool isSender; - DateTime? createdAt; + + /// When the message was created. + final DateTime? createdAt; + + /// Creates a chat message containing either [text] or [chatMedia], but not both. + /// + /// ```dart + /// // A text message: + /// final m1 = ChatMessage(text: 'Hello!', isSender: true); + /// + /// // A media message: + /// final m2 = ChatMessage( + /// chatMedia: ChatMedia(url: 'https://…', mediaType: MediaType.imageMediaType()), + /// isSender: false, + /// ); + /// ``` ChatMessage({ - this.text = '', + required this.text, this.chatMedia, required this.isSender, this.createdAt, }); + /// Returns a copy of this message, replacing only the given fields. ChatMessage copyWith({ String? text, ChatMedia? chatMedia, @@ -30,53 +58,51 @@ class ChatMessage { ); } + /// Converts this message to a JSON‐compatible map. Map toMap() { - return { - 'text': text, - 'chatMedia': chatMedia?.toMap(), - 'isSender': isSender, - 'createdAt': createdAt?.millisecondsSinceEpoch, + return { + _keyText: text, + _keyChatMedia: chatMedia?.toMap(), + _keyIsSender: isSender, + _keyCreatedAt: createdAt?.millisecondsSinceEpoch, }; } + /// Creates a message from a `Map`, e.g. from decoded JSON. factory ChatMessage.fromMap(Map map) { return ChatMessage( - text: map['text'] as String, - chatMedia: map['chatMedia'] != null - ? ChatMedia.fromMap(map['chatMedia'] as Map) + text: (map[_keyText] as String?) ?? '', + chatMedia: map[_keyChatMedia] != null + ? ChatMedia.fromMap(map[_keyChatMedia] as Map) : null, - isSender: map['isSender'] as bool, - createdAt: map['createdAt'] != null - ? DateTime.fromMillisecondsSinceEpoch(map['createdAt'] as int) + isSender: map[_keyIsSender] as bool, + createdAt: map[_keyCreatedAt] != null + ? DateTime.fromMillisecondsSinceEpoch(map[_keyCreatedAt] as int) : null, ); } - String toJson() => json.encode(toMap()); + /// Encodes this message to a JSON string. + String toJson() => jsonEncode(toMap()); - factory ChatMessage.fromJson(String source) => - ChatMessage.fromMap(json.decode(source) as Map); + /// Decodes a JSON string into a [ChatMessage]. + factory ChatMessage.fromJson(String jsonStr) => + ChatMessage.fromMap(jsonDecode(jsonStr) as Map); @override - String toString() { - return 'ChatMessage(text: $text, chatMedia: $chatMedia, isSender: $isSender, createdAt: $createdAt)'; - } + String toString() => + 'ChatMessage(text: $text, chatMedia: $chatMedia, isSender: $isSender, createdAt: $createdAt)'; @override - bool operator ==(covariant ChatMessage other) { + bool operator ==(Object other) { if (identical(this, other)) return true; - - return other.text == text && + return other is ChatMessage && + other.text == text && other.chatMedia == chatMedia && other.isSender == isSender && other.createdAt == createdAt; } @override - int get hashCode { - return text.hashCode ^ - chatMedia.hashCode ^ - isSender.hashCode ^ - createdAt.hashCode; - } + int get hashCode => Object.hash(text, chatMedia, isSender, createdAt); }