From b99253df20b8ff94541eae4d5229eec8eafb8eed Mon Sep 17 00:00:00 2001 From: "omar.mouki" Date: Tue, 20 May 2025 15:22:34 +0400 Subject: [PATCH 1/4] Improves audio message widget and adds receiver color - Refactors the audio message widget for better control and UI. - Adds a `receiverColor` property to the chat screen and message widgets, allowing customization of the receiver's message bubble color. - Sets default empty string to ChatMessage text to avoid null exceptions. --- example/lib/main.dart | 1 + lib/chat_package.dart | 6 +- .../chat_input_field_provider.dart | 2 + .../audio_message/audio_message_widget.dart | 222 +++++++++--------- lib/components/message/message_widget.dart | 107 +++++---- lib/models/chat_message.dart | 90 ++++--- 6 files changed, 236 insertions(+), 192 deletions(-) diff --git a/example/lib/main.dart b/example/lib/main.dart index 5abd0e7..7b352a5 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -42,6 +42,7 @@ class _MyHomePageState extends State { ), ), ChatMessage( + text: '', isSender: false, chatMedia: ChatMedia( url: diff --git a/lib/chat_package.dart b/lib/chat_package.dart index 583daa8..0de8058 100644 --- a/lib/chat_package.dart +++ b/lib/chat_package.dart @@ -13,6 +13,8 @@ class ChatScreen extends StatefulWidget { ///color of the inactive part of the audio slider final Color? inActiveAudioSliderColor; + final Color? receiverColor; + ///color of the active part of the audio slider final Color? activeAudioSliderColor; @@ -105,6 +107,7 @@ class ChatScreen extends StatefulWidget { this.senderColor, this.inActiveAudioSliderColor, this.activeAudioSliderColor, + this.receiverColor, required this.messages, required this.scrollController, this.sendMessageHintText = 'Enter message here', @@ -146,10 +149,11 @@ class _ChatScreenState extends State { controller: widget.scrollController, itemCount: widget.messages.length, itemBuilder: (context, index) => MessageWidget( + receiverColor: widget.receiverColor ?? kSecondaryColor, message: widget.messages[index], activeAudioSliderColor: widget.activeAudioSliderColor ?? kSecondaryColor, - inActiveAudioSliderColor: + inactiveAudioSliderColor: widget.inActiveAudioSliderColor ?? kLightColor, senderColor: widget.senderColor ?? kPrimaryColor, messageContainerTextStyle: widget.messageContainerTextStyle, 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..f5d2b8e 100644 --- a/lib/components/chat_input_field/chat_input_field_provider.dart +++ b/lib/components/chat_input_field/chat_input_field_provider.dart @@ -119,6 +119,7 @@ class ChatInputFieldProvider extends ChangeNotifier { onSlideToCancelRecord(); } else { final audioMessage = ChatMessage( + text: '', isSender: true, chatMedia: ChatMedia( url: source, @@ -207,6 +208,7 @@ class ChatInputFieldProvider extends ChangeNotifier { ChatMessage? _getImageMEssageFromPath(String? path) { if (path != null) { final imageMessage = ChatMessage( + text: '', isSender: true, chatMedia: ChatMedia( url: path, 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/message_widget.dart b/lib/components/message/message_widget.dart index 899852d..05b9c8f 100644 --- a/lib/components/message/message_widget.dart +++ b/lib/components/message/message_widget.dart @@ -1,84 +1,95 @@ +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 +/// Renders a chat bubble (text, image, audio, or video) plus its timestamp. +/// +/// Supports custom bubble colors for sender and receiver. class MessageWidget extends StatelessWidget { + /// The chat message data. + final ChatMessage message; + + /// Base color for the sender’s bubble. final Color senderColor; - final Color inActiveAudioSliderColor; + + /// Base color for the receiver’s bubble. + final Color receiverColor; + + /// Color of the inactive portion of the audio slider. + final Color inactiveAudioSliderColor; + + /// Color of the active portion of the audio slider. final Color activeAudioSliderColor; + + /// Optional text style for message content (used by ImageMessageWidget). final TextStyle? messageContainerTextStyle; + + /// Optional text style for the timestamp. final TextStyle? sendDateTextStyle; 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 isSender = message.isSender; + final alignment = isSender ? Alignment.centerRight : Alignment.centerLeft; + final crossAxis = + isSender ? CrossAxisAlignment.end : CrossAxisAlignment.start; + final bubbleColor = isSender ? senderColor : receiverColor; + + // Choose the correct content widget based on message type + final content = message.chatMedia == null + ? TextMessageWidget( + message: message, + senderColor: bubbleColor, + ) + : message.chatMedia!.mediaType.when( + imageMediaType: () => ImageMessageWidget( + message: message, + senderColor: bubbleColor, + messageContainerTextStyle: messageContainerTextStyle, + ), + audioMediaType: () => AudioMessageWidget( + message: message, + senderColor: bubbleColor, + inactiveAudioSliderColor: inactiveAudioSliderColor, + activeAudioSliderColor: activeAudioSliderColor, + ), + videoMediaType: () { + // TODO: implement a VideoMessageWidget + return const SizedBox.shrink(); + }, + ); + 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: crossAxis, children: [ - messageContent(message), - SizedBox( - height: 3, - ), + content, + const SizedBox(height: 3), DateTimeWidget( message: message, sendDateTextStyle: sendDateTextStyle, - ) + ), ], ), ), ); } - - Widget messageContent(ChatMessage message) { - /// check message type and render the right widget - 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(), - ); - } - } } 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); } From 79d3a3cb976bc2862cb621e72390fadab54b2e30 Mon Sep 17 00:00:00 2001 From: Omar Mouki Date: Thu, 22 May 2025 13:21:36 +0400 Subject: [PATCH 2/4] Revert "fix:Added option to change the text direction (#14)" (#21) This reverts commit 29624dda7af654c3ba3bcf6abd1ee395267d5c5d. --- lib/chat_package.dart | 5 ----- lib/components/chat_input_field/chat_input_field.dart | 6 +++--- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/lib/chat_package.dart b/lib/chat_package.dart index 583daa8..865cc20 100644 --- a/lib/chat_package.dart +++ b/lib/chat_package.dart @@ -94,9 +94,6 @@ class ChatScreen extends StatefulWidget { /// text style for the message container date final TextStyle? sendDateTextStyle; - /// text direction for chat input field - final TextDirection? textDirection; - /// this is an optional parameter to override the default attachment bottom sheet final Function(BuildContext context)? attachmentClick; @@ -128,7 +125,6 @@ class ChatScreen extends StatefulWidget { this.messageContainerTextStyle, this.sendDateTextStyle, this.attachmentClick, - this.textDirection, }) : super(key: key); @override @@ -181,7 +177,6 @@ class _ChatScreenState extends State { handleImageSelect: widget.handleImageSelect, onSlideToCancelRecord: widget.onSlideToCancelRecord ?? () {}, onTextSubmit: widget.onTextSubmit, - textDirection: widget.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..e95e261 100644 --- a/lib/components/chat_input_field/chat_input_field.dart +++ b/lib/components/chat_input_field/chat_input_field.dart @@ -49,7 +49,7 @@ class ChatInputField extends StatelessWidget { final Color chatInputFieldColor; // TODO right description - final TextDirection? textDirection; + final TextDirection textDirection; final BoxDecoration? chatInputFieldDecoration; /// The callback when slider is completed. This is the only required field. @@ -79,7 +79,7 @@ class ChatInputField extends StatelessWidget { this.buttonRadios = 35, required this.sendMessageHintText, required this.recordingNoteHintText, - this.textDirection, + this.textDirection = TextDirection.rtl, this.chatInputFieldDecoration, this.sliderButtonContent = const Icon( Icons.chevron_right, @@ -125,7 +125,7 @@ class ChatInputField extends StatelessWidget { child: IgnorePointer( ignoring: disableInput, child: Directionality( - textDirection: textDirection ?? TextDirection.rtl, + textDirection: textDirection, child: AnimatedContainer( duration: Duration(milliseconds: provider.duration), curve: Curves.ease, From eb65e8a1889f89610a762bd9af94b8ac786d1cb3 Mon Sep 17 00:00:00 2001 From: "omar.mouki" Date: Thu, 22 May 2025 13:23:39 +0400 Subject: [PATCH 3/4] Refactor chat_package to Clean Architecture, upgrade SDK, add DartDoc, and fix recording animation --- .../android/app/src/main/AndroidManifest.xml | 45 +- example/lib/main.dart | 98 ++-- example/pubspec.lock | 16 +- example/pubspec.yaml | 76 +-- lib/chat_package.dart | 340 +++++++----- .../chat_input_field/chat_input_field.dart | 502 +++++++++++------- .../chat_input_field_provider.dart | 344 ++++++------ .../widgets/chat_animated_button.dart | 68 --- .../widgets/chat_attachment_bottom_sheet.dart | 65 --- .../widgets/chat_drag_trail.dart | 26 - .../widgets/recording_button.dart | 114 ++++ .../widgets/wave_animation.dart | 86 +++ lib/components/message/date_time_widget.dart | 36 +- lib/components/message/message_widget.dart | 93 ++-- 14 files changed, 1044 insertions(+), 865 deletions(-) delete mode 100644 lib/components/chat_input_field/widgets/chat_animated_button.dart delete mode 100644 lib/components/chat_input_field/widgets/chat_attachment_bottom_sheet.dart delete mode 100644 lib/components/chat_input_field/widgets/chat_drag_trail.dart create mode 100644 lib/components/chat_input_field/widgets/recording_button.dart create mode 100644 lib/components/chat_input_field/widgets/wave_animation.dart 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 7b352a5..e11362a 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,95 +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( - text: '', - 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 0de8058..3ca0f48 100644 --- a/lib/chat_package.dart +++ b/lib/chat_package.dart @@ -1,191 +1,251 @@ +/// 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, - final Color? receiverColor; + /// Controller for the message list scrolling. + required this.scrollController, - ///color of the active part of the audio slider - final Color? activeAudioSliderColor; + /// Controller for the text input field. + required this.textEditingController, - ///[required]scrollController for the chat screen - final ScrollController scrollController; + /// 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, + + /// 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); - /// [required] the list of chat messages + /// 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; + + /// Controller for the text input field. + final TextEditingController textEditingController; + + /// Callback invoked when a text message is submitted. + final ValueChanged onTextSubmit; + + /// Callback invoked when an image message is selected. + final ValueChanged onImageSelected; + + /// Callback invoked when audio recording is completed. + final ValueChanged onRecordComplete; + + /// Flag to enable or disable user input. + final bool enableInput; + + /// Hint text displayed when recording audio. + final String recordingNoteHintText; - /// [required] function to handel image selection - /// this method will pass a [ChatMessage] - final Function(ChatMessage? imageMessage) handleImageSelect; + /// Text for the “From Camera” option in the attachment sheet. + final String cameraText; - /// to handel canceling of the record - final VoidCallback? onSlideToCancelRecord; + /// Icon for the “From Camera” option. + final Icon? cameraIcon; - ///TextEditingController to handel input text - final TextEditingController? textEditingController; + /// Text for the “From Gallery” option. + final String galleryText; - /// to change the appearance of the chat input field - final BoxDecoration? chatInputFieldDecoration; + /// Icon for the “From Gallery” option. + final Icon? galleryIcon; - /// use this flag to disable the input - final bool disableInput; + /// Text for the “Cancel” option. + final String cancelText; - /// git the chat input field padding - final EdgeInsets? chatInputFieldPadding; + /// Icon for the “Cancel” option. + final Icon? cancelIcon; - /// text style for the message container - final TextStyle? messageContainerTextStyle; + /// Style for the option labels in the bottom sheet. + final TextStyle? chatBottomSheetTextStyle; - /// text style for the message container date - final TextStyle? sendDateTextStyle; + /// Decoration for the text input field. + final InputDecoration? textFieldDecoration; - /// text direction for chat input field + /// Padding around the chat field. + final EdgeInsetsGeometry? chatFieldPadding; + + /// 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, - this.receiverColor, - 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(); -} + /// Active color for the audio slider. + final Color? activeAudioSliderColor; + + /// Inactive color for the audio slider. + final Color? inactiveAudioSliderColor; -class _ChatScreenState extends State { @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( - receiverColor: widget.receiverColor ?? kSecondaryColor, - 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..db2c212 100644 --- a/lib/components/chat_input_field/chat_input_field.dart +++ b/lib/components/chat_input_field/chat_input_field.dart @@ -1,225 +1,371 @@ 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/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; + /// Icon for the “From Camera” option. + /// used for [ChatBottomSheet] + final Icon? cameraIcon; - /// The callback when send is pressed. - final Function(ChatMessage text) onTextSubmit; + /// Label for the “From Gallery” option. + /// used for [ChatBottomSheet] + final String galleryText; - /// function to handle the selected image - final Function(ChatMessage? imageMessage) handleImageSelect; + /// Icon for the “From Gallery” option. + /// used for [ChatBottomSheet] + final Icon? galleryIcon; - /// function to handle the recorded audio - final Function(ChatMessage? audioMessage, bool canceled) handleRecord; + /// Label for the “Cancel” option. + /// used for [ChatBottomSheet] + final String cancelText; - final TextEditingController _textController = TextEditingController(); + /// Icon for the “Cancel” option. + /// used for [ChatBottomSheet] + final Icon? cancelIcon; - final EdgeInsets? chatInputFieldPadding; + /// Text style applied to all option labels. + /// used for [ChatBottomSheet] + final TextStyle? chatBottomSheetTextStyle; - /// use this flag to disable the input - final bool disableInput; + final ValueChanged onTextSubmit; - /// this is an optional parameter to override the default attachment bottom sheet - final Function(BuildContext context)? attachmentClick; + /// + 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, + ), + ], ), ); }, ), ); } +} + +/// 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; - /// 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), - ), + /// 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, + ), + ], ), - 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, - ); - }, + ); + } + + /// 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/chat_input_field_provider.dart b/lib/components/chat_input_field/chat_input_field_provider.dart index f5d2b8e..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,245 +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( - text: '', - 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 + // ───────────────────────────────────────────────────────────────────────── + + /// Initiates audio recording on long-press start if no text is present. + Future startRecording() async { + if (hasText) return; + if (!await _requestPermission(Permission.microphone)) return; - /// function used to record audio - void recordAudio() async { - final tempDir = await getTemporaryDirectory(); - final path = '${tempDir.path}/${DateTime.now().millisecondsSinceEpoch}.m4a'; + await _startAudioRecord(); + _isRecording = true; + _dragOffset = 0.0; + notifyListeners(); + } - if (await _record.isRecording()) { - _record.stop(); + /// 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_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/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 05b9c8f..47b74c2 100644 --- a/lib/components/message/message_widget.dart +++ b/lib/components/message/message_widget.dart @@ -6,31 +6,36 @@ import 'package:chat_package/components/message/text_message/text_message_widget import 'package:chat_package/models/chat_message.dart'; import 'package:chat_package/utils/constants.dart'; -/// Renders a chat bubble (text, image, audio, or video) plus its timestamp. +/// A chat bubble that renders text, image, audio (or future video) messages, +/// along with a timestamp. /// -/// Supports custom bubble colors for sender and receiver. +/// Aligns to the right for sender messages and to the left for receiver messages, +/// applying distinct bubble colors and styles. class MessageWidget extends StatelessWidget { - /// The chat message data. + /// Data for this chat message (text, media, timestamp, sender flag). final ChatMessage message; - /// Base color for the sender’s bubble. + /// Bubble color for messages sent by the user. final Color senderColor; - /// Base color for the receiver’s bubble. + /// Bubble color for messages received from others. final Color receiverColor; - /// Color of the inactive portion of the audio slider. + /// Inactive track color for audio message slider. final Color inactiveAudioSliderColor; - /// Color of the active portion of the audio slider. + /// Active track color for audio message slider. final Color activeAudioSliderColor; - /// Optional text style for message content (used by ImageMessageWidget). + /// Optional text style applied inside image message containers. final TextStyle? messageContainerTextStyle; - /// Optional text style for the timestamp. + /// 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, @@ -44,44 +49,21 @@ class MessageWidget extends StatelessWidget { @override Widget build(BuildContext context) { - final isSender = message.isSender; - final alignment = isSender ? Alignment.centerRight : Alignment.centerLeft; - final crossAxis = + final bool isSender = message.isSender; + final Alignment alignment = + isSender ? Alignment.centerRight : Alignment.centerLeft; + final CrossAxisAlignment crossAxisAlignment = isSender ? CrossAxisAlignment.end : CrossAxisAlignment.start; - final bubbleColor = isSender ? senderColor : receiverColor; - - // Choose the correct content widget based on message type - final content = message.chatMedia == null - ? TextMessageWidget( - message: message, - senderColor: bubbleColor, - ) - : message.chatMedia!.mediaType.when( - imageMediaType: () => ImageMessageWidget( - message: message, - senderColor: bubbleColor, - messageContainerTextStyle: messageContainerTextStyle, - ), - audioMediaType: () => AudioMessageWidget( - message: message, - senderColor: bubbleColor, - inactiveAudioSliderColor: inactiveAudioSliderColor, - activeAudioSliderColor: activeAudioSliderColor, - ), - videoMediaType: () { - // TODO: implement a VideoMessageWidget - return const SizedBox.shrink(); - }, - ); + final Color bubbleColor = isSender ? senderColor : receiverColor; return Padding( padding: const EdgeInsets.only(top: kDefaultPadding), child: Align( alignment: alignment, child: Column( - crossAxisAlignment: crossAxis, + crossAxisAlignment: crossAxisAlignment, children: [ - content, + _buildContent(bubbleColor), const SizedBox(height: 3), DateTimeWidget( message: message, @@ -92,4 +74,37 @@ class MessageWidget extends StatelessWidget { ), ); } + + /// 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) { + return TextMessageWidget( + message: message, + 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(); + }, + ); + } } From 3c0ec023f68b1b19f4559b97814731b99e77da86 Mon Sep 17 00:00:00 2001 From: "omar.mouki" Date: Thu, 22 May 2025 13:30:32 +0400 Subject: [PATCH 4/4] Removes ChatBottomSheet from input field Removes the ChatBottomSheet from the chat input field to relocate it to its own dedicated file. This change improves code organization and maintainability by isolating the bottom sheet component. --- .../chat_input_field/chat_input_field.dart | 99 +------------------ .../widgets/chat_bottom_sheet.dart | 99 +++++++++++++++++++ 2 files changed, 100 insertions(+), 98 deletions(-) create mode 100644 lib/components/chat_input_field/widgets/chat_bottom_sheet.dart diff --git a/lib/components/chat_input_field/chat_input_field.dart b/lib/components/chat_input_field/chat_input_field.dart index 64b40e6..445050b 100644 --- a/lib/components/chat_input_field/chat_input_field.dart +++ b/lib/components/chat_input_field/chat_input_field.dart @@ -1,4 +1,5 @@ import 'package:chat_package/components/chat_input_field/chat_input_field_provider.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'; @@ -274,101 +275,3 @@ class _ChatInputFieldState extends State ); } } - -/// 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_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, + ); + } +}