From dc2e31c39c805de5179b4311057b1b8a988e2397 Mon Sep 17 00:00:00 2001 From: Cityzen App Date: Tue, 8 Jul 2025 21:06:02 +0800 Subject: [PATCH] =?UTF-8?q?-=20feat:=20=E2=9C=A8=20File=20message=20type?= =?UTF-8?q?=20and=20trailing=20parameter=20(for=20custom=20file=20icon)=20?= =?UTF-8?q?in=20ChatView=20added?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/src/extensions/extensions.dart | 2 + .../send_message_configuration.dart | 9 +- lib/src/widgets/chatui_textfield.dart | 9 +- lib/src/widgets/file_message_view.dart | 220 ++++++++++++++++++ lib/src/widgets/message_view.dart | 26 ++- lib/src/widgets/reply_message_type_view.dart | 19 +- pubspec.yaml | 4 +- 7 files changed, 275 insertions(+), 14 deletions(-) create mode 100644 lib/src/widgets/file_message_view.dart diff --git a/lib/src/extensions/extensions.dart b/lib/src/extensions/extensions.dart index bf4c6989..52e06d3b 100644 --- a/lib/src/extensions/extensions.dart +++ b/lib/src/extensions/extensions.dart @@ -118,6 +118,8 @@ extension MessageTypes on MessageType { bool get isVoice => this == MessageType.voice; bool get isCustom => this == MessageType.custom; + + bool get isFile => this == MessageType.file; } /// Extension on ConnectionState for checking specific connection. diff --git a/lib/src/models/config_models/send_message_configuration.dart b/lib/src/models/config_models/send_message_configuration.dart index 33f79939..db8b4e0f 100644 --- a/lib/src/models/config_models/send_message_configuration.dart +++ b/lib/src/models/config_models/send_message_configuration.dart @@ -54,6 +54,7 @@ class SendMessageConfiguration { this.selectedImageViewHeight, this.imageBorderRadius, this.selectedImageViewBuilder, + this.trailing, // <--- ADDED THIS LINE }); /// Used to give background color to text field. @@ -127,6 +128,10 @@ class SendMessageConfiguration { /// Provides ability to build custom view for selected images in text field. final SelectedImageViewBuilder? selectedImageViewBuilder; + + /// Provides list of widgets that will be placed at the trailing end + /// of the text input field, typically used for custom action buttons. + final List? trailing; // <--- ADDED THIS FIELD } class ImagePickerIconsConfiguration { @@ -245,7 +250,7 @@ class ImagePickerConfiguration { final CameraDevice? preferredCameraDevice; /// Callback when image is picked from camera or gallery, - /// we can perform our task on image like adding crop options and return new image path + ///  we can perform our task on image like adding crop options and return new image path final ImagePickedCallback? onImagePicked; } @@ -329,4 +334,4 @@ class CancelRecordConfiguration { /// Provides callback on voice record cancel final VoidCallback? onCancel; -} +} \ No newline at end of file diff --git a/lib/src/widgets/chatui_textfield.dart b/lib/src/widgets/chatui_textfield.dart index 18d5589e..075bd425 100644 --- a/lib/src/widgets/chatui_textfield.dart +++ b/lib/src/widgets/chatui_textfield.dart @@ -84,6 +84,7 @@ class _ChatUITextFieldState extends State { VoiceRecordingConfiguration? get voiceRecordingConfig => widget.sendMessageConfig?.voiceRecordingConfiguration; + // FIX: Corrected getter name from imagePickerIconsConfiguration to imagePickerIconsConfig ImagePickerIconsConfiguration? get imagePickerIconsConfig => sendMessageConfig?.imagePickerIconsConfig; @@ -283,7 +284,7 @@ class _ChatUITextFieldState extends State { children: [ if (!isRecordingValue) ...[ if (sendMessageConfig?.enableCameraImagePicker ?? - true) + true) IconButton( constraints: const BoxConstraints(), onPressed: (textFieldConfig?.enabled ?? true) @@ -302,7 +303,7 @@ class _ChatUITextFieldState extends State { ), ), if (sendMessageConfig?.enableGalleryImagePicker ?? - true) + true) IconButton( constraints: const BoxConstraints(), onPressed: (textFieldConfig?.enabled ?? true) @@ -349,6 +350,8 @@ class _ChatUITextFieldState extends State { color: cancelRecordConfiguration?.iconColor ?? voiceRecordingConfig?.recorderIconColor, ), + if (sendMessageConfig?.trailing != null) + ...sendMessageConfig!.trailing!, ], ); } @@ -451,4 +454,4 @@ class _ChatUITextFieldState extends State { }); _inputText.value = inputText; } -} +} \ No newline at end of file diff --git a/lib/src/widgets/file_message_view.dart b/lib/src/widgets/file_message_view.dart new file mode 100644 index 00000000..4e2d9d68 --- /dev/null +++ b/lib/src/widgets/file_message_view.dart @@ -0,0 +1,220 @@ +import 'package:flutter/material.dart'; +import 'dart:io'; // Needed for File class +import 'package:chatview_utils/chatview_utils.dart'; // For Message model +import 'package:open_filex/open_filex.dart'; // Import open_filex +import 'package:url_launcher/url_launcher.dart'; // Import url_launcher +import 'package:flutter/foundation.dart' show kIsWeb; // Import kIsWeb + +class FileMessageView extends StatelessWidget { + const FileMessageView({ + Key? key, + required this.message, + required this.isMessageBySender, + }) : super(key: key); + + final Message message; + final bool isMessageBySender; + + // Function to check if a string is a valid HTTP/HTTPS URL + bool _isWebUrl(String urlString) { + try { + final uri = Uri.parse(urlString); + return uri.scheme == 'http' || uri.scheme == 'https'; + } catch (e) { + return false; + } + } + + void _onFileTap(BuildContext context, String pathOrUrl) async { + print('--- File Tap Debug Start ---'); + print('1. Input path/URL: $pathOrUrl'); + + if (kIsWeb) { + // On web, always try to open as a URL + try { + final uri = Uri.parse(pathOrUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.platformDefault); + print('Opened URL on Web: $pathOrUrl'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Opened web link: ${uri.host}')), + ); + } else { + print('Could not launch URL on Web: $pathOrUrl'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open web link.')), + ); + } + } catch (e) { + print('Error parsing or launching URL on Web: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Invalid URL or error opening: $pathOrUrl')), + ); + } + print('--- File Tap Debug End (Web) ---'); + return; // Exit as web handling is done + } + + // For non-web platforms (mobile, desktop) + if (_isWebUrl(pathOrUrl)) { + print('2. Detected as Web URL. Attempting to open with url_launcher.'); + try { + final uri = Uri.parse(pathOrUrl); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); // Open in external browser + print('Opened web URL: $pathOrUrl'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Opened web link: ${uri.host}')), + ); + } else { + print('Could not launch web URL: $pathOrUrl'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open web link: $pathOrUrl')), + ); + } + } catch (e) { + print('Error parsing or launching URL: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Invalid URL or error opening: $pathOrUrl')), + ); + } + } else { + // Assume it's a local file path + String actualFilePath; + if (pathOrUrl.startsWith('file:///')) { + actualFilePath = Uri.decodeComponent(pathOrUrl.substring('file:///'.length)); + } else { + actualFilePath = Uri.decodeComponent(pathOrUrl); + } + print('2. Detected as Local File Path: $actualFilePath. Attempting to open with OpenFilex.'); + + final File file = File(actualFilePath); + + if (await file.exists()) { + print('3. Local file exists at path: $actualFilePath'); + try { + final OpenResult result = await OpenFilex.open(actualFilePath); + print('4. OpenFilex result type: ${result.type}'); + print('5. OpenFilex result message: ${result.message}'); + + if (result.type == ResultType.done) { + print('File opened successfully!'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Opened file: ${file.path.split('/').last}')), + ); + } else { + print('Failed to open local file with OpenFilex. Error: ${result.message}'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Could not open file: ${file.path.split('/').last}. Error: ${result.message}')), + ); + } + } catch (e) { + print('6. Exception caught when trying to open local file with OpenFilex: $e'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('An unexpected error occurred: ${e.toString()}')), + ); + } + } else { + print('3. Local file DOES NOT exist at path: $actualFilePath'); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('File not found on device: ${file.path.split('/').last}')), + ); + } + } + print('--- File Tap Debug End ---'); + } + + @override + Widget build(BuildContext context) { + final String pathOrUrl = message.message; + + // Determine what to display based on whether it's a URL or a file path + String displayFileName; + IconData displayIcon; + Color iconColor = isMessageBySender ? Colors.blue[700]! : Colors.grey[700]!; + Color textColor = isMessageBySender ? Colors.blue[900]! : Colors.black87; + + if (_isWebUrl(pathOrUrl)) { + displayFileName = Uri.parse(pathOrUrl).host; // Show domain for URL + displayIcon = Icons.link; // Link icon for URLs + } else { + // Treat as a local file path + String actualFilePath; + if (pathOrUrl.startsWith('file:///')) { + actualFilePath = Uri.decodeComponent(pathOrUrl.substring('file:///'.length)); + } else { + actualFilePath = Uri.decodeComponent(pathOrUrl); + } + displayFileName = actualFilePath.split('/').last; // Show file name for local files + displayIcon = Icons.insert_drive_file; // Generic file icon + } + + return GestureDetector( + onTap: () => _onFileTap(context, pathOrUrl), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + margin: EdgeInsets.only( + top: 6, + right: isMessageBySender ? 6 : 0, + left: isMessageBySender ? 0 : 6, + bottom: message.reaction.reactions.isNotEmpty ? 15 : 0, + ), + decoration: BoxDecoration( + color: isMessageBySender ? Colors.blue[100] : Colors.grey[300], + borderRadius: BorderRadius.circular(14), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + displayIcon, + color: iconColor, + ), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayFileName, + style: TextStyle( + color: textColor, + fontWeight: FontWeight.bold, + ), + overflow: TextOverflow.ellipsis, + ), + // Conditional display for file size/status or URL status + _isWebUrl(pathOrUrl) + ? Text( + 'Web Link', + style: const TextStyle(fontSize: 12, color: Colors.black54), + ) + : FutureBuilder( + future: File(pathOrUrl.startsWith('file:///') ? Uri.decodeComponent(pathOrUrl.substring('file:///'.length)) : Uri.decodeComponent(pathOrUrl)).exists(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.done) { + if (snapshot.hasData && snapshot.data == true) { + final file = File(pathOrUrl.startsWith('file:///') ? Uri.decodeComponent(pathOrUrl.substring('file:///'.length)) : Uri.decodeComponent(pathOrUrl)); + return Text( + 'File exists (${(file.lengthSync() / 1024).toStringAsFixed(2)} KB)', + style: const TextStyle(fontSize: 12, color: Colors.black54), + ); + } else { + return Text( + 'File not found', + style: const TextStyle(fontSize: 12, color: Colors.red), + ); + } + } + return const Text('Checking file...', style: TextStyle(fontSize: 12, color: Colors.black54)); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/src/widgets/message_view.dart b/lib/src/widgets/message_view.dart index 55d757b9..525dc5db 100644 --- a/lib/src/widgets/message_view.dart +++ b/lib/src/widgets/message_view.dart @@ -32,6 +32,7 @@ import 'image_message_view.dart'; import 'reaction_widget.dart'; import 'text_message_view.dart'; import 'voice_message_view.dart'; +import 'file_message_view.dart'; // ADD THIS IMPORT class MessageView extends StatefulWidget { const MessageView({ @@ -212,7 +213,18 @@ class _MessageViewState extends State highlightImage: widget.shouldHighlight, highlightScale: widget.highlightScale, ); - } else if (widget.message.messageType.isText) { + } + // ADDED THIS ELSE IF FOR MessageType.file + else if (widget.message.messageType == MessageType.file) { + return FileMessageView( + message: widget.message, + isMessageBySender: widget.isMessageBySender, + // You can add more configurations here if FileMessageView needs them + // fileMessageConfig: messageConfig?.fileMessageConfig, // Uncomment if you add a fileMessageConfig to MessageConfiguration + ); + } + // END OF ADDED ELSE IF + else if (widget.message.messageType.isText) { return TextMessageView( inComingChatBubbleConfig: widget.inComingChatBubbleConfig, outgoingChatBubbleConfig: widget.outgoingChatBubbleConfig, @@ -252,11 +264,11 @@ class _MessageViewState extends State .lastSeenAgoBuilderVisibility ?? true) { return widget.outgoingChatBubbleConfig?.receiptsWidgetConfig - ?.lastSeenAgoBuilder - ?.call( - widget.message, - applicationDateFormatter( - widget.message.createdAt)) ?? + ?.lastSeenAgoBuilder + ?.call( + widget.message, + applicationDateFormatter( + widget.message.createdAt)) ?? lastSeenAgoBuilder(widget.message, applicationDateFormatter(widget.message.createdAt)); } @@ -283,4 +295,4 @@ class _MessageViewState extends State _animationController?.dispose(); super.dispose(); } -} +} \ No newline at end of file diff --git a/lib/src/widgets/reply_message_type_view.dart b/lib/src/widgets/reply_message_type_view.dart index 50fc6e55..15df2100 100644 --- a/lib/src/widgets/reply_message_type_view.dart +++ b/lib/src/widgets/reply_message_type_view.dart @@ -81,6 +81,23 @@ class ReplyMessageTypeView extends StatelessWidget { ), ], ), + // Case for MessageType.file - Corrected to use a hardcoded string for now + MessageType.file => Row( + children: [ + Icon( + Icons.file_copy, // Or any other suitable file icon + size: 20, + color: + sendMessageConfig?.replyMessageColor ?? Colors.grey.shade700, + ), + Text( + 'File', // Changed from PackageStrings.currentLocale.file to a hardcoded string + style: TextStyle( + color: sendMessageConfig?.replyMessageColor ?? Colors.black, + ), + ), + ], + ), MessageType.custom when customMessageReplyViewBuilder != null => customMessageReplyViewBuilder!(message), MessageType.custom || MessageType.text => Text( @@ -94,4 +111,4 @@ class ReplyMessageTypeView extends StatelessWidget { ), }; } -} +} \ No newline at end of file diff --git a/pubspec.yaml b/pubspec.yaml index a43f2a78..eb48c19e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,13 +19,15 @@ dependencies: any_link_preview: ^3.0.2 audio_waveforms: ^1.2.0 cached_network_image: ^3.4.1 - chatview_utils: ^0.0.1 + chatview_utils: + path: ../offline-chatview-utils emoji_picker_flutter: ^4.3.0 flutter: sdk: flutter image_picker: '>=0.8.9 <2.0.0' intl: ^0.20.0 url_launcher: ^6.3.0 + open_filex: ^4.3.2 dev_dependencies: flutter_lints: ^2.0.1