diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 00000000..5d19f763 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,253 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "flutter_chat_ui", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat", + "cwd": "examples/flyer_chat", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat (profile mode)", + "cwd": "examples/flyer_chat", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat (release mode)", + "cwd": "examples/flyer_chat", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "cross_cache", + "cwd": "packages/cross_cache", + "request": "launch", + "type": "dart" + }, + { + "name": "cross_cache (profile mode)", + "cwd": "packages/cross_cache", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "cross_cache (release mode)", + "cwd": "packages/cross_cache", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flutter_chat_core", + "cwd": "packages/flutter_chat_core", + "request": "launch", + "type": "dart" + }, + { + "name": "flutter_chat_core (profile mode)", + "cwd": "packages/flutter_chat_core", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flutter_chat_core (release mode)", + "cwd": "packages/flutter_chat_core", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flutter_chat_ui", + "cwd": "packages/flutter_chat_ui", + "request": "launch", + "type": "dart" + }, + { + "name": "flutter_chat_ui (profile mode)", + "cwd": "packages/flutter_chat_ui", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flutter_chat_ui (release mode)", + "cwd": "packages/flutter_chat_ui", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flyer_chat_audio_message", + "cwd": "packages/flyer_chat_audio_message", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat_audio_message (profile mode)", + "cwd": "packages/flyer_chat_audio_message", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat_audio_message (release mode)", + "cwd": "packages/flyer_chat_audio_message", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flyer_chat_custom_message", + "cwd": "packages/flyer_chat_custom_message", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat_custom_message (profile mode)", + "cwd": "packages/flyer_chat_custom_message", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat_custom_message (release mode)", + "cwd": "packages/flyer_chat_custom_message", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flyer_chat_file_message", + "cwd": "packages/flyer_chat_file_message", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat_file_message (profile mode)", + "cwd": "packages/flyer_chat_file_message", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat_file_message (release mode)", + "cwd": "packages/flyer_chat_file_message", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flyer_chat_image_message", + "cwd": "packages/flyer_chat_image_message", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat_image_message (profile mode)", + "cwd": "packages/flyer_chat_image_message", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat_image_message (release mode)", + "cwd": "packages/flyer_chat_image_message", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flyer_chat_location_message", + "cwd": "packages/flyer_chat_location_message", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat_location_message (profile mode)", + "cwd": "packages/flyer_chat_location_message", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat_location_message (release mode)", + "cwd": "packages/flyer_chat_location_message", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flyer_chat_system_message", + "cwd": "packages/flyer_chat_system_message", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat_system_message (profile mode)", + "cwd": "packages/flyer_chat_system_message", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat_system_message (release mode)", + "cwd": "packages/flyer_chat_system_message", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flyer_chat_text_message", + "cwd": "packages/flyer_chat_text_message", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat_text_message (profile mode)", + "cwd": "packages/flyer_chat_text_message", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat_text_message (release mode)", + "cwd": "packages/flyer_chat_text_message", + "request": "launch", + "type": "dart", + "flutterMode": "release" + }, + { + "name": "flyer_chat_video_message", + "cwd": "packages/flyer_chat_video_message", + "request": "launch", + "type": "dart" + }, + { + "name": "flyer_chat_video_message (profile mode)", + "cwd": "packages/flyer_chat_video_message", + "request": "launch", + "type": "dart", + "flutterMode": "profile" + }, + { + "name": "flyer_chat_video_message (release mode)", + "cwd": "packages/flyer_chat_video_message", + "request": "launch", + "type": "dart", + "flutterMode": "release" + } + ] +} \ No newline at end of file diff --git a/README.md b/README.md index 996e8b98..6daf67b4 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,47 @@ Welcome to the next generation of Flutter Chat UI! ✨ 💡 **Looking for the stable version?** The v1 release is available on the [v1 branch](https://github.com/flyerhq/flutter_chat_ui/tree/v1). + +## Platform-Specific Configuration for Audio Recording + +To enable audio recording functionality, you need to configure permissions for both Android and iOS platforms. + +### Android + +1. **Add Permissions to `AndroidManifest.xml`:** + + Add the following permissions to your `AndroidManifest.xml` file to allow microphone access and file writing: + + ```xml + + + + ``` + +2. **Add Queries for Android 11 and above:** + + If your app targets Android 11 (API level 30) or higher, add the following queries to allow access to certain actions: + + ```xml + + + + + + + ``` + +### iOS + +1. **Add Permissions to `Info.plist`:** + + Add the following keys to your `Info.plist` file to request microphone access: + + ```xml + NSMicrophoneUsageDescription + This app requires access to the microphone to record audio. + NSPhotoLibraryAddUsageDescription + This app requires access to save audio files. + ``` + +By following these steps, you can ensure that your app has the necessary permissions to record audio on both Android and iOS platforms. diff --git a/examples/flyer_chat/android/app/src/debug/AndroidManifest.xml b/examples/flyer_chat/android/app/src/debug/AndroidManifest.xml index 399f6981..ffa6aff6 100644 --- a/examples/flyer_chat/android/app/src/debug/AndroidManifest.xml +++ b/examples/flyer_chat/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,10 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + + + + + diff --git a/examples/flyer_chat/android/app/src/main/AndroidManifest.xml b/examples/flyer_chat/android/app/src/main/AndroidManifest.xml index c92e2ead..ef5423fc 100644 --- a/examples/flyer_chat/android/app/src/main/AndroidManifest.xml +++ b/examples/flyer_chat/android/app/src/main/AndroidManifest.xml @@ -1,4 +1,13 @@ + + + + + + + + + UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + NSMicrophoneUsageDescription + Some message to describe why you need this permission diff --git a/examples/flyer_chat/lib/api.dart b/examples/flyer_chat/lib/api.dart index 2b834433..863f4baa 100644 --- a/examples/flyer_chat/lib/api.dart +++ b/examples/flyer_chat/lib/api.dart @@ -58,10 +58,8 @@ class ApiState extends State { return Scaffold( body: Chat( builders: Builders( - textMessageBuilder: (context, message) => - FlyerChatTextMessage(message: message), - imageMessageBuilder: (context, message) => - FlyerChatImageMessage(message: message), + textMessageBuilder: (context, message) => FlyerChatTextMessage(message: message), + imageMessageBuilder: (context, message) => FlyerChatImageMessage(message: message), ), chatController: _chatController, user: widget.author, @@ -145,7 +143,7 @@ class ApiState extends State { }); } - void _addItem(String? text) async { + Future _addItem(String? text) async { final message = await createMessage(widget.author, widget.dio, text: text); if (mounted) { @@ -159,8 +157,6 @@ class ApiState extends State { ); if (mounted) { - // Make sure to get the updated message - // (width and height might have been set by the image message widget) final possiblyUpdatedMessage = _chatController.messages.firstWhere( (element) => element.id == message.id, orElse: () => message, diff --git a/examples/flyer_chat/lib/create_message.dart b/examples/flyer_chat/lib/create_message.dart index a8651740..a2184b51 100644 --- a/examples/flyer_chat/lib/create_message.dart +++ b/examples/flyer_chat/lib/create_message.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'dart:math'; import 'package:dio/dio.dart'; @@ -5,67 +6,71 @@ import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_lorem/flutter_lorem.dart'; import 'package:uuid/uuid.dart'; +enum MessageType { text, image, audio } + Future createMessage( User author, Dio dio, { - bool? textOnly, + MessageType type = MessageType.text, String? text, + File? file, }) async { const uuid = Uuid(); - Message message; - if (Random().nextBool() || textOnly == true || text != null) { - message = TextMessage( - id: uuid.v4(), - author: author, - createdAt: DateTime.now().toUtc(), - text: text ?? lorem(paragraphs: 1, words: Random().nextInt(30) + 1), - ); - } else { - final orientation = ['portrait', 'square', 'wide'][Random().nextInt(3)]; - late double width, height; + switch (type) { + case MessageType.text: + return TextMessage( + id: uuid.v4(), + author: author, + createdAt: DateTime.now().toUtc(), + text: text ?? lorem(paragraphs: 1, words: Random().nextInt(30) + 1), + ); + case MessageType.image: + final orientation = ['portrait', 'square', 'wide'][Random().nextInt(3)]; + late double width, height; - if (orientation == 'portrait') { - width = 200; - height = 400; - } else if (orientation == 'square') { - width = 200; - height = 200; - } else { - width = 400; - height = 200; - } + if (orientation == 'portrait') { + width = 200; + height = 400; + } else if (orientation == 'square') { + width = 200; + height = 200; + } else { + width = 400; + height = 200; + } - final response = await dio.get( - 'https://whatever.diamanthq.dev/image?w=${width.toInt()}&h=${height.toInt()}&seed=${Random().nextInt(501)}', - options: Options( - headers: { - 'Access-Control-Allow-Origin': '*', - 'Content-Type': 'application/json', - 'Accept': '*/*', - }, - ), - ); + final response = await dio.get( + 'https://whatever.diamanthq.dev/image?w=${width.toInt()}&h=${height.toInt()}&seed=${Random().nextInt(501)}', + options: Options( + headers: { + 'Access-Control-Allow-Origin': '*', + 'Content-Type': 'application/json', + 'Accept': '*/*', + }, + ), + ); - message = ImageMessage( - id: uuid.v4(), - author: author, - createdAt: DateTime.now().toUtc(), - source: response.data['img'], - thumbhash: response.data['thumbhash'], - blurhash: response.data['blurhash'], - ); + return ImageMessage( + id: uuid.v4(), + author: author, + createdAt: DateTime.now().toUtc(), + source: response.data['img'], + thumbhash: response.data['thumbhash'], + blurhash: response.data['blurhash'], + ); + case MessageType.audio: + if (file != null) { + return AudioMessage( + id: uuid.v4(), + author: author, + createdAt: DateTime.now().toUtc(), + audioFile: file.path, + ); + } else { + throw ArgumentError('File must be provided for audio messages'); + } + default: + throw ArgumentError('Invalid message type: $type'); } - - // return ImageMessage( - // id: uuid.v4(), - // author: author, - // createdAt: DateTime.now().toUtc(), - // source: - // 'https://www.hdcarwallpapers.com/walls/audi_r8_spyder_v10_performance_rwd_2021_4k_8k-HD.jpg', - // thumbhash: '2gcODIKwdmg9eId1l4qTb2v4xw', - // blurhash: 'LPFFjU00^+IV~W4n%LRkROM|WBxu', - // ); - - return message; } diff --git a/examples/flyer_chat/lib/gemini.dart b/examples/flyer_chat/lib/gemini.dart index 95c12920..e555cc73 100644 --- a/examples/flyer_chat/lib/gemini.dart +++ b/examples/flyer_chat/lib/gemini.dart @@ -45,10 +45,7 @@ class GeminiState extends State { ); _chatSession = _model.startChat( - history: _chatController.messages - .whereType() - .map((message) => Content.text(message.text)) - .toList(), + history: _chatController.messages.whereType().map((message) => Content.text(message.text)).toList(), ); } @@ -65,10 +62,8 @@ class GeminiState extends State { return Scaffold( body: Chat( builders: Builders( - textMessageBuilder: (context, message) => - FlyerChatTextMessage(message: message), - imageMessageBuilder: (context, message) => - FlyerChatImageMessage(message: message), + textMessageBuilder: (context, message) => FlyerChatTextMessage(message: message), + imageMessageBuilder: (context, message) => FlyerChatImageMessage(message: message), ), chatController: _chatController, crossCache: _crossCache, @@ -156,8 +151,7 @@ class GeminiState extends State { ); await _chatController.insert(_currentGeminiResponse!); } else { - final newUpdatedMessage = (_currentGeminiResponse as TextMessage) - .copyWith(text: accumulatedText); + final newUpdatedMessage = (_currentGeminiResponse as TextMessage).copyWith(text: accumulatedText); await _chatController.update( _currentGeminiResponse!, newUpdatedMessage, @@ -168,9 +162,7 @@ class GeminiState extends State { // as soon as message that is being generated reaches top of the viewport WidgetsBinding.instance.addPostFrameCallback((_) { if (!_scrollController.hasClients || !mounted) return; - if ((_scrollController.position.maxScrollExtent - - initialMaxScrollExtent) < - viewportDimension) { + if ((_scrollController.position.maxScrollExtent - initialMaxScrollExtent) < viewportDimension) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 250), diff --git a/examples/flyer_chat/lib/hive_chat_controller.dart b/examples/flyer_chat/lib/hive_chat_controller.dart index dec7c961..a41c336b 100644 --- a/examples/flyer_chat/lib/hive_chat_controller.dart +++ b/examples/flyer_chat/lib/hive_chat_controller.dart @@ -26,11 +26,7 @@ class HiveChatController implements ChatController { @override Future remove(Message message) async { - final index = _box - .getRange(0, _box.length) - .map((json) => Message.fromJson(json)) - .toList() - .indexOf(message); + final index = _box.getRange(0, _box.length).map((json) => Message.fromJson(json)).toList().indexOf(message); if (index > -1) { _box.write(() { @@ -44,11 +40,7 @@ class HiveChatController implements ChatController { Future update(Message oldMessage, Message newMessage) async { if (oldMessage == newMessage) return; - final index = _box - .getRange(0, _box.length) - .map((json) => Message.fromJson(json)) - .toList() - .indexOf(oldMessage); + final index = _box.getRange(0, _box.length).map((json) => Message.fromJson(json)).toList().indexOf(oldMessage); if (index > -1) { _box.write(() { @@ -67,10 +59,7 @@ class HiveChatController implements ChatController { } else { _box.write(() { _box.putAll( - messages - .map((message) => {message.id: message.toJson()}) - .toList() - .reduce((acc, map) => {...acc, ...map}), + messages.map((message) => {message.id: message.toJson()}).toList().reduce((acc, map) => {...acc, ...map}), ); _operationsController.add(ChatOperation.set()); }); @@ -79,10 +68,7 @@ class HiveChatController implements ChatController { @override List get messages { - return _box - .getRange(0, _box.length) - .map((json) => Message.fromJson(json)) - .toList(); + return _box.getRange(0, _box.length).map((json) => Message.fromJson(json)).toList(); } @override diff --git a/examples/flyer_chat/lib/local.dart b/examples/flyer_chat/lib/local.dart index f4ea04bc..065320c8 100644 --- a/examples/flyer_chat/lib/local.dart +++ b/examples/flyer_chat/lib/local.dart @@ -1,9 +1,11 @@ +import 'dart:io'; import 'dart:math'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; +import 'package:flyer_chat_audio_message/flyer_chat_audio_message.dart'; import 'package:flyer_chat_image_message/flyer_chat_image_message.dart'; import 'package:flyer_chat_text_message/flyer_chat_text_message.dart'; import 'package:image_picker/image_picker.dart'; @@ -34,18 +36,20 @@ class LocalState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: Chat( - builders: Builders( - textMessageBuilder: (context, message) => - FlyerChatTextMessage(message: message), - imageMessageBuilder: (context, message) => - FlyerChatImageMessage(message: message), + body: SafeArea( + child: Chat( + builders: Builders( + textMessageBuilder: (context, message) => FlyerChatTextMessage(message: message), + audioMessageBuilder: (context, message) => FlyerChatAudioMessage(message: message), + imageMessageBuilder: (context, message) => FlyerChatImageMessage(message: message), + ), + chatController: _chatController, + user: widget.author, + onMessageSend: _addItem, + onAudioSend: _addAudio, + onMessageTap: _removeItem, + onAttachmentTap: _handleAttachmentTap, ), - chatController: _chatController, - user: widget.author, - onMessageSend: _addItem, - onMessageTap: _removeItem, - onAttachmentTap: _handleAttachmentTap, ), persistentFooterButtons: [ TextButton( @@ -71,9 +75,7 @@ class LocalState extends State { } void _addItem(String? text) async { - final randomUser = Random().nextInt(2) == 0 - ? const User(id: 'sender1') - : const User(id: 'sender2'); + final randomUser = Random().nextInt(2) == 0 ? const User(id: 'sender1') : const User(id: 'sender2'); final message = await createMessage(randomUser, widget.dio, text: text); @@ -102,4 +104,17 @@ class LocalState extends State { await _chatController.insert(imageMessage); } } + + Future _addAudio(File file) async { + final message = await createMessage( + widget.author, + widget.dio, + type: MessageType.audio, + file: file, + ); + + if (mounted) { + await _chatController.insert(message); + } + } } diff --git a/examples/flyer_chat/lib/main.dart b/examples/flyer_chat/lib/main.dart index 163ac94f..3eadd3e9 100644 --- a/examples/flyer_chat/lib/main.dart +++ b/examples/flyer_chat/lib/main.dart @@ -71,207 +71,209 @@ class _FlyerChatHomePageState extends State { @override Widget build(BuildContext context) { return Scaffold( - body: Center( - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 8), - SegmentedButton( - segments: const >[ - ButtonSegment( - value: 'sender1', - label: Text('sender1'), - ), - ButtonSegment( - value: 'sender2', - label: Text('sender2'), - ), - ], - selected: {_author.id}, - onSelectionChanged: (Set newSender) { - setState(() { - // By default there is only a single segment that can be - // selected at one time, so its value is always the first - // item in the selected set. - _author = User(id: newSender.first); - }); - }, - ), - const SizedBox(height: 8), - SizedBox( - width: 200, - child: TextField( - controller: _chatIdController, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'chat id', + body: SafeArea( + child: Center( + child: SingleChildScrollView( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 8), + SegmentedButton( + segments: const >[ + ButtonSegment( + value: 'sender1', + label: Text('sender1'), + ), + ButtonSegment( + value: 'sender2', + label: Text('sender2'), + ), + ], + selected: {_author.id}, + onSelectionChanged: (Set newSender) { + setState(() { + // By default there is only a single segment that can be + // selected at one time, so its value is always the first + // item in the selected set. + _author = User(id: newSender.first); + }); + }, + ), + const SizedBox(height: 8), + SizedBox( + width: 200, + child: TextField( + controller: _chatIdController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'chat id', + ), ), ), - ), - const SizedBox(height: 8), - Wrap( - alignment: WrapAlignment.center, - spacing: 8, - runSpacing: 8, - children: [ - ElevatedButton( - onPressed: () { - getInitialMessages(_dio, chatId: _chatIdController.text) - .then((messages) { - if (mounted && context.mounted) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => Api( - author: _author, - chatId: _chatIdController.text, - initialMessages: messages, - dio: _dio, - ), - ), - ); - } - }).catchError((error) { - if (mounted && context.mounted) { - debugPrint(error.toString()); - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Error'), - content: const Text( - 'Make sure the chat ID is correct', + const SizedBox(height: 8), + Wrap( + alignment: WrapAlignment.center, + spacing: 8, + runSpacing: 8, + children: [ + ElevatedButton( + onPressed: () { + getInitialMessages(_dio, chatId: _chatIdController.text) + .then((messages) { + if (mounted && context.mounted) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Api( + author: _author, + chatId: _chatIdController.text, + initialMessages: messages, + dio: _dio, ), - actions: [ - TextButton( - child: const Text('OK'), - onPressed: () => - Navigator.of(context).pop(), - ), - ], - ); - }, - ); - } - }); - }, - child: const Text('api'), - ), - ElevatedButton( - onPressed: () { - showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - title: const Text('Confirmation'), - content: const Text( - 'Are you sure you want to generate a new chat ID?', - ), - actions: [ - TextButton( - child: const Text('Cancel'), - onPressed: () => Navigator.of(context).pop(), ), - TextButton( - child: const Text('Yes'), - onPressed: () { - Navigator.of(context).pop(); - getChatId(_dio).then((chatId) { - if (mounted && context.mounted) { - _chatIdController.text = chatId; - } - }); - }, + ); + } + }).catchError((error) { + if (mounted && context.mounted) { + debugPrint(error.toString()); + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Error'), + content: const Text( + 'Make sure the chat ID is correct', + ), + actions: [ + TextButton( + child: const Text('OK'), + onPressed: () => + Navigator.of(context).pop(), + ), + ], + ); + }, + ); + } + }); + }, + child: const Text('api'), + ), + ElevatedButton( + onPressed: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: const Text('Confirmation'), + content: const Text( + 'Are you sure you want to generate a new chat ID?', ), - ], - ); - }, - ); - }, - child: const Text('generate chat id'), - ), - ElevatedButton( - onPressed: () { - Clipboard.setData( - ClipboardData(text: _chatIdController.text), - ); - }, - child: const Text('copy chat id'), + actions: [ + TextButton( + child: const Text('Cancel'), + onPressed: () => Navigator.of(context).pop(), + ), + TextButton( + child: const Text('Yes'), + onPressed: () { + Navigator.of(context).pop(); + getChatId(_dio).then((chatId) { + if (mounted && context.mounted) { + _chatIdController.text = chatId; + } + }); + }, + ), + ], + ); + }, + ); + }, + child: const Text('generate chat id'), + ), + ElevatedButton( + onPressed: () { + Clipboard.setData( + ClipboardData(text: _chatIdController.text), + ); + }, + child: const Text('copy chat id'), + ), + ], + ), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'In order to test the api, you need to generate a chat id. Chat will be reset after 24 hours. Use the same chat id to access chat on different devices.', + textAlign: TextAlign.center, ), - ], - ), - const SizedBox(height: 8), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'In order to test the api, you need to generate a chat id. Chat will be reset after 24 hours. Use the same chat id to access chat on different devices.', - textAlign: TextAlign.center, ), - ), - const SizedBox( - width: 200, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Divider( - color: Colors.grey, - thickness: 1, + const SizedBox( + width: 200, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Divider( + color: Colors.grey, + thickness: 1, + ), ), ), - ), - ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => Local(author: _author, dio: _dio), + ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Local(author: _author, dio: _dio), + ), + ); + }, + child: const Text('local'), + ), + const SizedBox( + width: 200, + child: Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Divider( + color: Colors.grey, + thickness: 1, ), - ); - }, - child: const Text('local'), - ), - const SizedBox( - width: 200, - child: Padding( - padding: EdgeInsets.symmetric(vertical: 8), - child: Divider( - color: Colors.grey, - thickness: 1, ), ), - ), - SizedBox( - width: 200, - child: TextField( - controller: _geminiApiKeyController, - decoration: const InputDecoration( - border: OutlineInputBorder(), - hintText: 'gemini api key', + SizedBox( + width: 200, + child: TextField( + controller: _geminiApiKeyController, + decoration: const InputDecoration( + border: OutlineInputBorder(), + hintText: 'gemini api key', + ), ), ), - ), - const SizedBox(height: 8), - ElevatedButton( - onPressed: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (context) => Gemini( - geminiApiKey: _geminiApiKeyController.text, - database: widget.database, + const SizedBox(height: 8), + ElevatedButton( + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => Gemini( + geminiApiKey: _geminiApiKeyController.text, + database: widget.database, + ), ), - ), - ); - }, - child: const Text('gemini'), - ), - const SizedBox(height: 8), - const Padding( - padding: EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'In order to test the AI example, you need to provide your own Gemini API key', - textAlign: TextAlign.center, + ); + }, + child: const Text('gemini'), + ), + const SizedBox(height: 8), + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'In order to test the AI example, you need to provide your own Gemini API key', + textAlign: TextAlign.center, + ), ), - ), - const SizedBox(height: 8), - ], + const SizedBox(height: 8), + ], + ), ), ), ), diff --git a/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc b/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc index 02bc14a7..b42b2618 100644 --- a/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc +++ b/examples/flyer_chat/linux/flutter/generated_plugin_registrant.cc @@ -6,14 +6,22 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); + audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); g_autoptr(FlPluginRegistrar) isar_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "IsarFlutterLibsPlugin"); isar_flutter_libs_plugin_register_with_registrar(isar_flutter_libs_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); } diff --git a/examples/flyer_chat/linux/flutter/generated_plugins.cmake b/examples/flyer_chat/linux/flutter/generated_plugins.cmake index 00bef184..fb848f3a 100644 --- a/examples/flyer_chat/linux/flutter/generated_plugins.cmake +++ b/examples/flyer_chat/linux/flutter/generated_plugins.cmake @@ -3,8 +3,10 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_linux file_selector_linux isar_flutter_libs + record_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift b/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift index 0b202afd..d9caf934 100644 --- a/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/examples/flyer_chat/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,12 +5,16 @@ import FlutterMacOS import Foundation +import audioplayers_darwin import file_selector_macos import isar_flutter_libs import path_provider_foundation +import record_darwin func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) IsarFlutterLibsPlugin.register(with: registry.registrar(forPlugin: "IsarFlutterLibsPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) } diff --git a/examples/flyer_chat/macos/Runner/Info.plist b/examples/flyer_chat/macos/Runner/Info.plist index 4789daa6..db5da9ed 100644 --- a/examples/flyer_chat/macos/Runner/Info.plist +++ b/examples/flyer_chat/macos/Runner/Info.plist @@ -28,5 +28,9 @@ MainMenu NSPrincipalClass NSApplication + NSMicrophoneUsageDescription + Some message to describe why you need this permission + com.apple.security.device.audio-input + diff --git a/examples/flyer_chat/pubspec.yaml b/examples/flyer_chat/pubspec.yaml index 914f16d8..3fe82e31 100644 --- a/examples/flyer_chat/pubspec.yaml +++ b/examples/flyer_chat/pubspec.yaml @@ -38,6 +38,7 @@ dependencies: flutter_chat_core: ^0.0.2 flutter_chat_ui: ^2.0.0-dev.1 flutter_lorem: ^2.0.0 + flyer_chat_audio_message: ^0.0.2 flyer_chat_image_message: ^0.0.2 flyer_chat_text_message: ^0.0.2 google_generative_ai: ^0.4.5 diff --git a/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc b/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc index df32f874..439d5f4b 100644 --- a/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc +++ b/examples/flyer_chat/windows/flutter/generated_plugin_registrant.cc @@ -6,12 +6,21 @@ #include "generated_plugin_registrant.h" +#include #include #include +#include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { + AudioplayersWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); IsarFlutterLibsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("IsarFlutterLibsPlugin")); + PermissionHandlerWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); } diff --git a/examples/flyer_chat/windows/flutter/generated_plugins.cmake b/examples/flyer_chat/windows/flutter/generated_plugins.cmake index 52869ab3..1cc4d573 100644 --- a/examples/flyer_chat/windows/flutter/generated_plugins.cmake +++ b/examples/flyer_chat/windows/flutter/generated_plugins.cmake @@ -3,8 +3,11 @@ # list(APPEND FLUTTER_PLUGIN_LIST + audioplayers_windows file_selector_windows isar_flutter_libs + permission_handler_windows + record_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/packages/flutter_chat_core/lib/src/models/builders.dart b/packages/flutter_chat_core/lib/src/models/builders.dart index 4dd041ae..d2edecfd 100644 --- a/packages/flutter_chat_core/lib/src/models/builders.dart +++ b/packages/flutter_chat_core/lib/src/models/builders.dart @@ -6,6 +6,7 @@ import 'message.dart'; part 'builders.freezed.dart'; typedef TextMessageBuilder = Widget Function(BuildContext, TextMessage); +typedef AudioMessageBuilder = Widget Function(BuildContext, AudioMessage); typedef ImageMessageBuilder = Widget Function(BuildContext, ImageMessage); typedef UnsupportedMessageBuilder = Widget Function( BuildContext, @@ -34,6 +35,7 @@ class Builders with _$Builders { const factory Builders({ TextMessageBuilder? textMessageBuilder, ImageMessageBuilder? imageMessageBuilder, + AudioMessageBuilder? audioMessageBuilder, UnsupportedMessageBuilder? unsupportedMessageBuilder, InputBuilder? inputBuilder, ChatMessageBuilder? chatMessageBuilder, diff --git a/packages/flutter_chat_core/lib/src/models/builders.freezed.dart b/packages/flutter_chat_core/lib/src/models/builders.freezed.dart index ff9a9871..67966bcb 100644 --- a/packages/flutter_chat_core/lib/src/models/builders.freezed.dart +++ b/packages/flutter_chat_core/lib/src/models/builders.freezed.dart @@ -20,6 +20,8 @@ mixin _$Builders { throw _privateConstructorUsedError; ImageMessageBuilder? get imageMessageBuilder => throw _privateConstructorUsedError; + AudioMessageBuilder? get audioMessageBuilder => + throw _privateConstructorUsedError; UnsupportedMessageBuilder? get unsupportedMessageBuilder => throw _privateConstructorUsedError; InputBuilder? get inputBuilder => throw _privateConstructorUsedError; @@ -45,6 +47,7 @@ abstract class $BuildersCopyWith<$Res> { $Res call( {TextMessageBuilder? textMessageBuilder, ImageMessageBuilder? imageMessageBuilder, + AudioMessageBuilder? audioMessageBuilder, UnsupportedMessageBuilder? unsupportedMessageBuilder, InputBuilder? inputBuilder, ChatMessageBuilder? chatMessageBuilder, @@ -69,6 +72,7 @@ class _$BuildersCopyWithImpl<$Res, $Val extends Builders> $Res call({ Object? textMessageBuilder = freezed, Object? imageMessageBuilder = freezed, + Object? audioMessageBuilder = freezed, Object? unsupportedMessageBuilder = freezed, Object? inputBuilder = freezed, Object? chatMessageBuilder = freezed, @@ -84,6 +88,10 @@ class _$BuildersCopyWithImpl<$Res, $Val extends Builders> ? _value.imageMessageBuilder : imageMessageBuilder // ignore: cast_nullable_to_non_nullable as ImageMessageBuilder?, + audioMessageBuilder: freezed == audioMessageBuilder + ? _value.audioMessageBuilder + : audioMessageBuilder // ignore: cast_nullable_to_non_nullable + as AudioMessageBuilder?, unsupportedMessageBuilder: freezed == unsupportedMessageBuilder ? _value.unsupportedMessageBuilder : unsupportedMessageBuilder // ignore: cast_nullable_to_non_nullable @@ -119,6 +127,7 @@ abstract class _$$BuildersImplCopyWith<$Res> $Res call( {TextMessageBuilder? textMessageBuilder, ImageMessageBuilder? imageMessageBuilder, + AudioMessageBuilder? audioMessageBuilder, UnsupportedMessageBuilder? unsupportedMessageBuilder, InputBuilder? inputBuilder, ChatMessageBuilder? chatMessageBuilder, @@ -141,6 +150,7 @@ class __$$BuildersImplCopyWithImpl<$Res> $Res call({ Object? textMessageBuilder = freezed, Object? imageMessageBuilder = freezed, + Object? audioMessageBuilder = freezed, Object? unsupportedMessageBuilder = freezed, Object? inputBuilder = freezed, Object? chatMessageBuilder = freezed, @@ -156,6 +166,10 @@ class __$$BuildersImplCopyWithImpl<$Res> ? _value.imageMessageBuilder : imageMessageBuilder // ignore: cast_nullable_to_non_nullable as ImageMessageBuilder?, + audioMessageBuilder: freezed == audioMessageBuilder + ? _value.audioMessageBuilder + : audioMessageBuilder // ignore: cast_nullable_to_non_nullable + as AudioMessageBuilder?, unsupportedMessageBuilder: freezed == unsupportedMessageBuilder ? _value.unsupportedMessageBuilder : unsupportedMessageBuilder // ignore: cast_nullable_to_non_nullable @@ -186,6 +200,7 @@ class _$BuildersImpl extends _Builders { const _$BuildersImpl( {this.textMessageBuilder, this.imageMessageBuilder, + this.audioMessageBuilder, this.unsupportedMessageBuilder, this.inputBuilder, this.chatMessageBuilder, @@ -198,6 +213,8 @@ class _$BuildersImpl extends _Builders { @override final ImageMessageBuilder? imageMessageBuilder; @override + final AudioMessageBuilder? audioMessageBuilder; + @override final UnsupportedMessageBuilder? unsupportedMessageBuilder; @override final InputBuilder? inputBuilder; @@ -210,7 +227,7 @@ class _$BuildersImpl extends _Builders { @override String toString() { - return 'Builders(textMessageBuilder: $textMessageBuilder, imageMessageBuilder: $imageMessageBuilder, unsupportedMessageBuilder: $unsupportedMessageBuilder, inputBuilder: $inputBuilder, chatMessageBuilder: $chatMessageBuilder, chatAnimatedListBuilder: $chatAnimatedListBuilder, scrollToBottomBuilder: $scrollToBottomBuilder)'; + return 'Builders(textMessageBuilder: $textMessageBuilder, imageMessageBuilder: $imageMessageBuilder, audioMessageBuilder: $audioMessageBuilder, unsupportedMessageBuilder: $unsupportedMessageBuilder, inputBuilder: $inputBuilder, chatMessageBuilder: $chatMessageBuilder, chatAnimatedListBuilder: $chatAnimatedListBuilder, scrollToBottomBuilder: $scrollToBottomBuilder)'; } @override @@ -222,6 +239,8 @@ class _$BuildersImpl extends _Builders { other.textMessageBuilder == textMessageBuilder) && (identical(other.imageMessageBuilder, imageMessageBuilder) || other.imageMessageBuilder == imageMessageBuilder) && + (identical(other.audioMessageBuilder, audioMessageBuilder) || + other.audioMessageBuilder == audioMessageBuilder) && (identical(other.unsupportedMessageBuilder, unsupportedMessageBuilder) || other.unsupportedMessageBuilder == unsupportedMessageBuilder) && @@ -241,6 +260,7 @@ class _$BuildersImpl extends _Builders { runtimeType, textMessageBuilder, imageMessageBuilder, + audioMessageBuilder, unsupportedMessageBuilder, inputBuilder, chatMessageBuilder, @@ -260,6 +280,7 @@ abstract class _Builders extends Builders { const factory _Builders( {final TextMessageBuilder? textMessageBuilder, final ImageMessageBuilder? imageMessageBuilder, + final AudioMessageBuilder? audioMessageBuilder, final UnsupportedMessageBuilder? unsupportedMessageBuilder, final InputBuilder? inputBuilder, final ChatMessageBuilder? chatMessageBuilder, @@ -272,6 +293,8 @@ abstract class _Builders extends Builders { @override ImageMessageBuilder? get imageMessageBuilder; @override + AudioMessageBuilder? get audioMessageBuilder; + @override UnsupportedMessageBuilder? get unsupportedMessageBuilder; @override InputBuilder? get inputBuilder; diff --git a/packages/flutter_chat_core/lib/src/models/message.dart b/packages/flutter_chat_core/lib/src/models/message.dart index 40a89650..d4bdbd0a 100644 --- a/packages/flutter_chat_core/lib/src/models/message.dart +++ b/packages/flutter_chat_core/lib/src/models/message.dart @@ -30,6 +30,14 @@ sealed class Message with _$Message { double? height, }) = ImageMessage; + const factory Message.audio({ + required String id, + required User author, + Map? metadata, + @EpochDateTimeConverter() required DateTime createdAt, + required String audioFile, + }) = AudioMessage; + const factory Message.unsupported({ required String id, required User author, @@ -39,6 +47,5 @@ sealed class Message with _$Message { const Message._(); - factory Message.fromJson(Map json) => - _$MessageFromJson(json); + factory Message.fromJson(Map json) => _$MessageFromJson(json); } diff --git a/packages/flutter_chat_core/lib/src/models/message.freezed.dart b/packages/flutter_chat_core/lib/src/models/message.freezed.dart index eacfcc68..14072447 100644 --- a/packages/flutter_chat_core/lib/src/models/message.freezed.dart +++ b/packages/flutter_chat_core/lib/src/models/message.freezed.dart @@ -20,6 +20,8 @@ Message _$MessageFromJson(Map json) { return TextMessage.fromJson(json); case 'image': return ImageMessage.fromJson(json); + case 'audio': + return AudioMessage.fromJson(json); default: return UnsupportedMessage.fromJson(json); @@ -54,6 +56,13 @@ mixin _$Message { double? width, double? height) image, + required TResult Function( + String id, + User author, + Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, + String audioFile) + audio, required TResult Function( String id, User author, @@ -83,6 +92,9 @@ mixin _$Message { double? width, double? height)? image, + TResult? Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, TResult? Function( String id, User author, @@ -112,6 +124,9 @@ mixin _$Message { double? width, double? height)? image, + TResult Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, TResult Function( String id, User author, @@ -125,6 +140,7 @@ mixin _$Message { TResult map({ required TResult Function(TextMessage value) text, required TResult Function(ImageMessage value) image, + required TResult Function(AudioMessage value) audio, required TResult Function(UnsupportedMessage value) unsupported, }) => throw _privateConstructorUsedError; @@ -132,6 +148,7 @@ mixin _$Message { TResult? mapOrNull({ TResult? Function(TextMessage value)? text, TResult? Function(ImageMessage value)? image, + TResult? Function(AudioMessage value)? audio, TResult? Function(UnsupportedMessage value)? unsupported, }) => throw _privateConstructorUsedError; @@ -139,6 +156,7 @@ mixin _$Message { TResult maybeMap({ TResult Function(TextMessage value)? text, TResult Function(ImageMessage value)? image, + TResult Function(AudioMessage value)? audio, TResult Function(UnsupportedMessage value)? unsupported, required TResult orElse(), }) => @@ -400,6 +418,13 @@ class _$TextMessageImpl extends TextMessage { double? width, double? height) image, + required TResult Function( + String id, + User author, + Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, + String audioFile) + audio, required TResult Function( String id, User author, @@ -432,6 +457,9 @@ class _$TextMessageImpl extends TextMessage { double? width, double? height)? image, + TResult? Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, TResult? Function( String id, User author, @@ -464,6 +492,9 @@ class _$TextMessageImpl extends TextMessage { double? width, double? height)? image, + TResult Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, TResult Function( String id, User author, @@ -483,6 +514,7 @@ class _$TextMessageImpl extends TextMessage { TResult map({ required TResult Function(TextMessage value) text, required TResult Function(ImageMessage value) image, + required TResult Function(AudioMessage value) audio, required TResult Function(UnsupportedMessage value) unsupported, }) { return text(this); @@ -493,6 +525,7 @@ class _$TextMessageImpl extends TextMessage { TResult? mapOrNull({ TResult? Function(TextMessage value)? text, TResult? Function(ImageMessage value)? image, + TResult? Function(AudioMessage value)? audio, TResult? Function(UnsupportedMessage value)? unsupported, }) { return text?.call(this); @@ -503,6 +536,7 @@ class _$TextMessageImpl extends TextMessage { TResult maybeMap({ TResult Function(TextMessage value)? text, TResult Function(ImageMessage value)? image, + TResult Function(AudioMessage value)? audio, TResult Function(UnsupportedMessage value)? unsupported, required TResult orElse(), }) { @@ -760,6 +794,13 @@ class _$ImageMessageImpl extends ImageMessage { double? width, double? height) image, + required TResult Function( + String id, + User author, + Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, + String audioFile) + audio, required TResult Function( String id, User author, @@ -793,6 +834,9 @@ class _$ImageMessageImpl extends ImageMessage { double? width, double? height)? image, + TResult? Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, TResult? Function( String id, User author, @@ -826,6 +870,9 @@ class _$ImageMessageImpl extends ImageMessage { double? width, double? height)? image, + TResult Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, TResult Function( String id, User author, @@ -846,6 +893,7 @@ class _$ImageMessageImpl extends ImageMessage { TResult map({ required TResult Function(TextMessage value) text, required TResult Function(ImageMessage value) image, + required TResult Function(AudioMessage value) audio, required TResult Function(UnsupportedMessage value) unsupported, }) { return image(this); @@ -856,6 +904,7 @@ class _$ImageMessageImpl extends ImageMessage { TResult? mapOrNull({ TResult? Function(TextMessage value)? text, TResult? Function(ImageMessage value)? image, + TResult? Function(AudioMessage value)? audio, TResult? Function(UnsupportedMessage value)? unsupported, }) { return image?.call(this); @@ -866,6 +915,7 @@ class _$ImageMessageImpl extends ImageMessage { TResult maybeMap({ TResult Function(TextMessage value)? text, TResult Function(ImageMessage value)? image, + TResult Function(AudioMessage value)? audio, TResult Function(UnsupportedMessage value)? unsupported, required TResult orElse(), }) { @@ -922,6 +972,330 @@ abstract class ImageMessage extends Message { throw _privateConstructorUsedError; } +/// @nodoc +abstract class _$$AudioMessageImplCopyWith<$Res> + implements $MessageCopyWith<$Res> { + factory _$$AudioMessageImplCopyWith( + _$AudioMessageImpl value, $Res Function(_$AudioMessageImpl) then) = + __$$AudioMessageImplCopyWithImpl<$Res>; + @override + @useResult + $Res call( + {String id, + User author, + Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, + String audioFile}); + + @override + $UserCopyWith<$Res> get author; +} + +/// @nodoc +class __$$AudioMessageImplCopyWithImpl<$Res> + extends _$MessageCopyWithImpl<$Res, _$AudioMessageImpl> + implements _$$AudioMessageImplCopyWith<$Res> { + __$$AudioMessageImplCopyWithImpl( + _$AudioMessageImpl _value, $Res Function(_$AudioMessageImpl) _then) + : super(_value, _then); + + /// Create a copy of Message + /// with the given fields replaced by the non-null parameter values. + @pragma('vm:prefer-inline') + @override + $Res call({ + Object? id = null, + Object? author = null, + Object? metadata = freezed, + Object? createdAt = null, + Object? audioFile = null, + }) { + return _then(_$AudioMessageImpl( + id: null == id + ? _value.id + : id // ignore: cast_nullable_to_non_nullable + as String, + author: null == author + ? _value.author + : author // ignore: cast_nullable_to_non_nullable + as User, + metadata: freezed == metadata + ? _value._metadata + : metadata // ignore: cast_nullable_to_non_nullable + as Map?, + createdAt: null == createdAt + ? _value.createdAt + : createdAt // ignore: cast_nullable_to_non_nullable + as DateTime, + audioFile: null == audioFile + ? _value.audioFile + : audioFile // ignore: cast_nullable_to_non_nullable + as String, + )); + } +} + +/// @nodoc +@JsonSerializable() +class _$AudioMessageImpl extends AudioMessage { + const _$AudioMessageImpl( + {required this.id, + required this.author, + final Map? metadata, + @EpochDateTimeConverter() required this.createdAt, + required this.audioFile, + final String? $type}) + : _metadata = metadata, + $type = $type ?? 'audio', + super._(); + + factory _$AudioMessageImpl.fromJson(Map json) => + _$$AudioMessageImplFromJson(json); + + @override + final String id; + @override + final User author; + final Map? _metadata; + @override + Map? get metadata { + final value = _metadata; + if (value == null) return null; + if (_metadata is EqualUnmodifiableMapView) return _metadata; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(value); + } + + @override + @EpochDateTimeConverter() + final DateTime createdAt; + @override + final String audioFile; + + @JsonKey(name: 'type') + final String $type; + + @override + String toString() { + return 'Message.audio(id: $id, author: $author, metadata: $metadata, createdAt: $createdAt, audioFile: $audioFile)'; + } + + @override + bool operator ==(Object other) { + return identical(this, other) || + (other.runtimeType == runtimeType && + other is _$AudioMessageImpl && + (identical(other.id, id) || other.id == id) && + (identical(other.author, author) || other.author == author) && + const DeepCollectionEquality().equals(other._metadata, _metadata) && + (identical(other.createdAt, createdAt) || + other.createdAt == createdAt) && + (identical(other.audioFile, audioFile) || + other.audioFile == audioFile)); + } + + @JsonKey(includeFromJson: false, includeToJson: false) + @override + int get hashCode => Object.hash(runtimeType, id, author, + const DeepCollectionEquality().hash(_metadata), createdAt, audioFile); + + /// Create a copy of Message + /// with the given fields replaced by the non-null parameter values. + @JsonKey(includeFromJson: false, includeToJson: false) + @override + @pragma('vm:prefer-inline') + _$$AudioMessageImplCopyWith<_$AudioMessageImpl> get copyWith => + __$$AudioMessageImplCopyWithImpl<_$AudioMessageImpl>(this, _$identity); + + @override + @optionalTypeArgs + TResult when({ + required TResult Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata, + String text, + LinkPreview? linkPreview) + text, + required TResult Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata, + String source, + String? thumbhash, + String? blurhash, + double? width, + double? height) + image, + required TResult Function( + String id, + User author, + Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, + String audioFile) + audio, + required TResult Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata) + unsupported, + }) { + return audio(id, author, metadata, createdAt, audioFile); + } + + @override + @optionalTypeArgs + TResult? whenOrNull({ + TResult? Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata, + String text, + LinkPreview? linkPreview)? + text, + TResult? Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata, + String source, + String? thumbhash, + String? blurhash, + double? width, + double? height)? + image, + TResult? Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, + TResult? Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata)? + unsupported, + }) { + return audio?.call(id, author, metadata, createdAt, audioFile); + } + + @override + @optionalTypeArgs + TResult maybeWhen({ + TResult Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata, + String text, + LinkPreview? linkPreview)? + text, + TResult Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata, + String source, + String? thumbhash, + String? blurhash, + double? width, + double? height)? + image, + TResult Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, + TResult Function( + String id, + User author, + @EpochDateTimeConverter() DateTime createdAt, + Map? metadata)? + unsupported, + required TResult orElse(), + }) { + if (audio != null) { + return audio(id, author, metadata, createdAt, audioFile); + } + return orElse(); + } + + @override + @optionalTypeArgs + TResult map({ + required TResult Function(TextMessage value) text, + required TResult Function(ImageMessage value) image, + required TResult Function(AudioMessage value) audio, + required TResult Function(UnsupportedMessage value) unsupported, + }) { + return audio(this); + } + + @override + @optionalTypeArgs + TResult? mapOrNull({ + TResult? Function(TextMessage value)? text, + TResult? Function(ImageMessage value)? image, + TResult? Function(AudioMessage value)? audio, + TResult? Function(UnsupportedMessage value)? unsupported, + }) { + return audio?.call(this); + } + + @override + @optionalTypeArgs + TResult maybeMap({ + TResult Function(TextMessage value)? text, + TResult Function(ImageMessage value)? image, + TResult Function(AudioMessage value)? audio, + TResult Function(UnsupportedMessage value)? unsupported, + required TResult orElse(), + }) { + if (audio != null) { + return audio(this); + } + return orElse(); + } + + @override + Map toJson() { + return _$$AudioMessageImplToJson( + this, + ); + } +} + +abstract class AudioMessage extends Message { + const factory AudioMessage( + {required final String id, + required final User author, + final Map? metadata, + @EpochDateTimeConverter() required final DateTime createdAt, + required final String audioFile}) = _$AudioMessageImpl; + const AudioMessage._() : super._(); + + factory AudioMessage.fromJson(Map json) = + _$AudioMessageImpl.fromJson; + + @override + String get id; + @override + User get author; + @override + Map? get metadata; + @override + @EpochDateTimeConverter() + DateTime get createdAt; + String get audioFile; + + /// Create a copy of Message + /// with the given fields replaced by the non-null parameter values. + @override + @JsonKey(includeFromJson: false, includeToJson: false) + _$$AudioMessageImplCopyWith<_$AudioMessageImpl> get copyWith => + throw _privateConstructorUsedError; +} + /// @nodoc abstract class _$$UnsupportedMessageImplCopyWith<$Res> implements $MessageCopyWith<$Res> { @@ -1068,6 +1442,13 @@ class _$UnsupportedMessageImpl extends UnsupportedMessage { double? width, double? height) image, + required TResult Function( + String id, + User author, + Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, + String audioFile) + audio, required TResult Function( String id, User author, @@ -1100,6 +1481,9 @@ class _$UnsupportedMessageImpl extends UnsupportedMessage { double? width, double? height)? image, + TResult? Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, TResult? Function( String id, User author, @@ -1132,6 +1516,9 @@ class _$UnsupportedMessageImpl extends UnsupportedMessage { double? width, double? height)? image, + TResult Function(String id, User author, Map? metadata, + @EpochDateTimeConverter() DateTime createdAt, String audioFile)? + audio, TResult Function( String id, User author, @@ -1151,6 +1538,7 @@ class _$UnsupportedMessageImpl extends UnsupportedMessage { TResult map({ required TResult Function(TextMessage value) text, required TResult Function(ImageMessage value) image, + required TResult Function(AudioMessage value) audio, required TResult Function(UnsupportedMessage value) unsupported, }) { return unsupported(this); @@ -1161,6 +1549,7 @@ class _$UnsupportedMessageImpl extends UnsupportedMessage { TResult? mapOrNull({ TResult? Function(TextMessage value)? text, TResult? Function(ImageMessage value)? image, + TResult? Function(AudioMessage value)? audio, TResult? Function(UnsupportedMessage value)? unsupported, }) { return unsupported?.call(this); @@ -1171,6 +1560,7 @@ class _$UnsupportedMessageImpl extends UnsupportedMessage { TResult maybeMap({ TResult Function(TextMessage value)? text, TResult Function(ImageMessage value)? image, + TResult Function(AudioMessage value)? audio, TResult Function(UnsupportedMessage value)? unsupported, required TResult orElse(), }) { diff --git a/packages/flutter_chat_core/lib/src/models/message.g.dart b/packages/flutter_chat_core/lib/src/models/message.g.dart index 359927c5..987bcfbd 100644 --- a/packages/flutter_chat_core/lib/src/models/message.g.dart +++ b/packages/flutter_chat_core/lib/src/models/message.g.dart @@ -78,6 +78,36 @@ Map _$$ImageMessageImplToJson(_$ImageMessageImpl instance) { return val; } +_$AudioMessageImpl _$$AudioMessageImplFromJson(Map json) => + _$AudioMessageImpl( + id: json['id'] as String, + author: User.fromJson(json['author'] as Map), + metadata: json['metadata'] as Map?, + createdAt: const EpochDateTimeConverter() + .fromJson((json['createdAt'] as num).toInt()), + audioFile: json['audioFile'] as String, + $type: json['type'] as String?, + ); + +Map _$$AudioMessageImplToJson(_$AudioMessageImpl instance) { + final val = { + 'id': instance.id, + 'author': instance.author.toJson(), + }; + + void writeNotNull(String key, dynamic value) { + if (value != null) { + val[key] = value; + } + } + + writeNotNull('metadata', instance.metadata); + val['createdAt'] = const EpochDateTimeConverter().toJson(instance.createdAt); + val['audioFile'] = instance.audioFile; + val['type'] = instance.$type; + return val; +} + _$UnsupportedMessageImpl _$$UnsupportedMessageImplFromJson( Map json) => _$UnsupportedMessageImpl( diff --git a/packages/flutter_chat_ui/lib/src/chat.dart b/packages/flutter_chat_ui/lib/src/chat.dart index c3291ed5..30a6f2b9 100644 --- a/packages/flutter_chat_ui/lib/src/chat.dart +++ b/packages/flutter_chat_ui/lib/src/chat.dart @@ -20,6 +20,7 @@ class Chat extends StatefulWidget { final ChatTheme? darkTheme; final ThemeMode themeMode; final OnMessageSendCallback? onMessageSend; + final OnAudioSendCallback? onAudioSend; final OnMessageTapCallback? onMessageTap; final OnAttachmentTapCallback? onAttachmentTap; @@ -34,6 +35,7 @@ class Chat extends StatefulWidget { this.darkTheme, this.themeMode = ThemeMode.system, this.onMessageSend, + this.onAudioSend, this.onMessageTap, this.onAttachmentTap, }); @@ -69,8 +71,7 @@ class _ChatState extends State with WidgetsBindingObserver { void didUpdateWidget(covariant Chat oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.theme != widget.theme || - oldWidget.darkTheme != widget.darkTheme) { + if (oldWidget.theme != widget.theme || oldWidget.darkTheme != widget.darkTheme) { _updateTheme(theme: _theme, darkTheme: _theme); } @@ -104,6 +105,7 @@ class _ChatState extends State with WidgetsBindingObserver { Provider.value(value: _builders), Provider.value(value: _crossCache), Provider.value(value: widget.onMessageSend), + Provider.value(value: widget.onAudioSend), Provider.value(value: widget.onMessageTap), Provider.value(value: widget.onAttachmentTap), ChangeNotifierProvider(create: (_) => ChatInputHeightNotifier()), @@ -151,10 +153,9 @@ class _ChatState extends State with WidgetsBindingObserver { _theme = (darkTheme ?? ChatTheme.dark()).merge(widget.darkTheme); break; case ThemeMode.system: - _theme = - PlatformDispatcher.instance.platformBrightness == Brightness.dark - ? (darkTheme ?? ChatTheme.dark()).merge(widget.darkTheme) - : (theme ?? ChatTheme.light()).merge(widget.theme); + _theme = PlatformDispatcher.instance.platformBrightness == Brightness.dark + ? (darkTheme ?? ChatTheme.dark()).merge(widget.darkTheme) + : (theme ?? ChatTheme.light()).merge(widget.theme); break; } } diff --git a/packages/flutter_chat_ui/lib/src/chat_input.dart b/packages/flutter_chat_ui/lib/src/chat_input.dart index 24d33c29..68c8e4b4 100644 --- a/packages/flutter_chat_ui/lib/src/chat_input.dart +++ b/packages/flutter_chat_ui/lib/src/chat_input.dart @@ -1,26 +1,58 @@ +import 'dart:developer' as developer; +import 'dart:io'; import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:provider/provider.dart'; +import 'package:record/record.dart'; +import './wave_animation.dart'; import 'utils/chat_input_height_notifier.dart'; import 'utils/typedefs.dart'; +/// A widget that provides a chat input interface with text, attachment, and audio recording capabilities. class ChatInput extends StatefulWidget { + /// The left position of the chat input. final double? left; + + /// The right position of the chat input. final double? right; + + /// The top position of the chat input. final double? top; + + /// The bottom position of the chat input. final double? bottom; + + /// The horizontal blur radius for the backdrop filter. final double? sigmaX; + + /// The vertical blur radius for the backdrop filter. final double? sigmaY; + + /// The padding around the chat input. final EdgeInsetsGeometry? padding; + + /// The icon for attachments. final Widget? attachmentIcon; + + /// The icon for sending messages. final Widget? sendIcon; + + /// The icon for audio recording. + final Widget? audioIcon; + + /// The gap between elements in the chat input. final double? gap; + + /// The border for the text input field. final InputBorder? inputBorder; + + /// Whether the text input field is filled. final bool? filled; + /// Creates a [ChatInput] widget. const ChatInput({ super.key, this.left = 0, @@ -32,6 +64,7 @@ class ChatInput extends StatefulWidget { this.padding = const EdgeInsets.all(8.0), this.attachmentIcon = const Icon(Icons.attachment), this.sendIcon = const Icon(Icons.send), + this.audioIcon = const Icon(Icons.mic), this.gap = 8, this.inputBorder = const OutlineInputBorder( borderSide: BorderSide.none, @@ -47,6 +80,9 @@ class ChatInput extends StatefulWidget { class _ChatInputState extends State { final GlobalKey _inputKey = GlobalKey(); final TextEditingController _textController = TextEditingController(); + final AudioRecorder _audioRecorder = AudioRecorder(); + bool _isRecording = false; + String? _recordedAudioPath; @override void initState() { @@ -57,13 +93,13 @@ class _ChatInputState extends State { @override void dispose() { _textController.dispose(); + _audioRecorder.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final backgroundColor = - context.select((ChatTheme theme) => theme.backgroundColor); + final backgroundColor = context.select((ChatTheme theme) => theme.backgroundColor); final inputTheme = context.select((ChatTheme theme) => theme.inputTheme); final onAttachmentTap = context.read(); @@ -76,7 +112,6 @@ class _ChatInputState extends State { child: ClipRect( child: BackdropFilter( filter: ImageFilter.blur( - // TODO: remove backdrop filter if both are 0 sigmaX: widget.sigmaX ?? 0, sigmaY: widget.sigmaY ?? 0, ), @@ -84,43 +119,61 @@ class _ChatInputState extends State { key: _inputKey, color: backgroundColor.withOpacity(0.8), child: Padding( - // TODO: remove padding if it's 0 padding: widget.padding ?? EdgeInsets.zero, child: Row( children: [ - widget.attachmentIcon != null - ? IconButton( - icon: widget.attachmentIcon!, - color: inputTheme.hintStyle?.color, - onPressed: onAttachmentTap, - ) - : const SizedBox.shrink(), - SizedBox(width: widget.gap), - Expanded( - child: TextField( - controller: _textController, - decoration: InputDecoration( - hintText: 'Type a message', - hintStyle: inputTheme.hintStyle, - border: widget.inputBorder, - filled: widget.filled, - fillColor: inputTheme.backgroundColor, - hoverColor: Colors.transparent, - ), - style: inputTheme.textStyle, - onSubmitted: _handleSubmitted, - textInputAction: TextInputAction.send, + if (widget.attachmentIcon != null && !_isRecording) + IconButton( + icon: widget.attachmentIcon!, + color: inputTheme.hintStyle?.color, + onPressed: onAttachmentTap, ), + if (!_isRecording) SizedBox(width: widget.gap), + Expanded( + child: _isRecording + ? _buildRecordingIndicator() + : TextField( + controller: _textController, + decoration: InputDecoration( + hintText: 'Type a message', + hintStyle: inputTheme.hintStyle, + border: widget.inputBorder, + filled: widget.filled, + fillColor: inputTheme.backgroundColor, + hoverColor: Colors.transparent, + ), + style: inputTheme.textStyle, + onSubmitted: _handleSubmitted, + textInputAction: TextInputAction.send, + ), ), SizedBox(width: widget.gap), - widget.sendIcon != null - ? IconButton( - icon: widget.sendIcon!, - color: inputTheme.hintStyle?.color, - onPressed: () => - _handleSubmitted(_textController.text), - ) - : const SizedBox.shrink(), + ValueListenableBuilder( + valueListenable: _textController, + builder: (context, value, child) { + return widget.sendIcon != null + ? Container( + decoration: const BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + ), + child: IconButton( + icon: value.text.isNotEmpty || _isRecording ? widget.sendIcon! : widget.audioIcon!, + color: inputTheme.hintStyle?.color, + onPressed: () async { + if (_isRecording) { + _handleAudioSubmitted(); + } else if (value.text.isEmpty) { + await _handleRecordAudio(); + } else { + _handleSubmitted(value.text); + } + }, + ), + ) + : const SizedBox.shrink(); + }, + ), ], ), ), @@ -130,13 +183,63 @@ class _ChatInputState extends State { ); } + Widget _buildRecordingIndicator() { + final streamAmplitude = _audioRecorder.onAmplitudeChanged(const Duration(seconds: 1)); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.circle, color: Colors.red), + const SizedBox(width: 8), + StreamBuilder( + stream: Stream.periodic( + const Duration(seconds: 1), + (count) => Duration(seconds: count), + ), + builder: (context, snapshot) { + final duration = snapshot.data ?? Duration.zero; + final minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); + final seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); + return Row( + children: [ + Text( + '$minutes:$seconds', + style: const TextStyle( + color: Colors.red, + fontSize: 16, + fontWeight: FontWeight.w900, + ), + ), + const SizedBox(width: 8), + StreamBuilder( + stream: streamAmplitude, + builder: (context, snapshot) { + final current = snapshot.data?.current ?? 0.0; + final max = snapshot.data?.max ?? 0.0; + final audioLevel = (current / max).clamp(0.0, 1.0); + + developer.log('current: $current', name: 'ChatInput'); + developer.log('max: $max', name: 'ChatInput'); + developer.log('audioLevel: $audioLevel', name: 'ChatInput'); + + return BarAnimation( + color: Colors.red, + audioLevel: audioLevel, + ); + }, + ), + ], + ); + }, + ), + ], + ); + } + void _updateInputHeight() { - final renderBox = - _inputKey.currentContext?.findRenderObject() as RenderBox?; + final renderBox = _inputKey.currentContext?.findRenderObject() as RenderBox?; if (renderBox != null) { - context - .read() - .updateHeight(renderBox.size.height); + context.read().updateHeight(renderBox.size.height); } } @@ -146,4 +249,44 @@ class _ChatInputState extends State { _textController.clear(); } } + + void _handleAudioSubmitted() { + _audioRecorder.stop(); + setState(() { + _isRecording = false; + }); + + if (_recordedAudioPath!.isNotEmpty) { + final audioFile = File(_recordedAudioPath!); + context.read()?.call(audioFile); + _resetAudioState(); + } + } + + Future _handleRecordAudio() async { + try { + final hasPermission = await _audioRecorder.hasPermission(); + + if (hasPermission) { + setState(() { + _isRecording = true; + }); + + _recordedAudioPath = 'audio.mp3'; + + await _audioRecorder.start( + const RecordConfig(), + path: 'assets/${_recordedAudioPath!}', + ); + } + } catch (e) { + debugPrint('Error during audio recording: $e'); + } + } + + void _resetAudioState() { + setState(() { + _recordedAudioPath = null; + }); + } } diff --git a/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart b/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart index 360b4456..073e7b4d 100644 --- a/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart +++ b/packages/flutter_chat_ui/lib/src/chat_message/chat_message_internal.dart @@ -7,11 +7,18 @@ import 'package:provider/provider.dart'; import '../simple_text_message.dart'; import 'chat_message.dart'; +/// A widget that represents an internal chat message with animation and update capabilities. class ChatMessageInternal extends StatefulWidget { + /// Animation for the message. final Animation animation; + + /// The message to be displayed. final Message message; + + /// Indicates if the message is removed. final bool? isRemoved; + /// Creates a [ChatMessageInternal] widget. const ChatMessageInternal({ super.key, required this.animation, @@ -23,6 +30,7 @@ class ChatMessageInternal extends StatefulWidget { State createState() => ChatMessageInternalState(); } +/// State for [ChatMessageInternal] that handles message updates and rendering. class ChatMessageInternalState extends State { late StreamSubscription? _operationsSubscription; late Message _updatedMessage; @@ -36,8 +44,7 @@ class ChatMessageInternalState extends State { if (widget.isRemoved == true) { _operationsSubscription = null; } else { - final chatController = - Provider.of(context, listen: false); + final chatController = Provider.of(context, listen: false); _operationsSubscription = chatController.operationsStream.listen((event) { switch (event.type) { case ChatOperationType.update: @@ -63,8 +70,8 @@ class ChatMessageInternalState extends State { @override void dispose() { - super.dispose(); _operationsSubscription?.cancel(); + super.dispose(); } @override @@ -92,11 +99,9 @@ class ChatMessageInternalState extends State { ) { switch (message) { case TextMessage(): - return builders.textMessageBuilder?.call(context, message) ?? - SimpleTextMessage(message: message); + return builders.textMessageBuilder?.call(context, message) ?? SimpleTextMessage(message: message); case ImageMessage(): - final result = builders.imageMessageBuilder?.call(context, message) ?? - const SizedBox.shrink(); + final result = builders.imageMessageBuilder?.call(context, message) ?? const SizedBox.shrink(); assert( !(result is SizedBox && result.width == 0 && result.height == 0), 'You are trying to display an image message but you have not provided an imageMessageBuilder. ' @@ -104,6 +109,11 @@ class ChatMessageInternalState extends State { 'If you want to use default image message widget, install flyer_chat_image_message package and use FlyerChatImageMessage widget.', ); return result; + case AudioMessage(): + return builders.audioMessageBuilder?.call(context, message) ?? + const Text( + 'Audio message received.', + ); case UnsupportedMessage(): return builders.unsupportedMessageBuilder?.call(context, message) ?? const Text( diff --git a/packages/flutter_chat_ui/lib/src/utils/typedefs.dart b/packages/flutter_chat_ui/lib/src/utils/typedefs.dart index 6978adf0..c33ee7e4 100644 --- a/packages/flutter_chat_ui/lib/src/utils/typedefs.dart +++ b/packages/flutter_chat_ui/lib/src/utils/typedefs.dart @@ -1,7 +1,9 @@ +import 'dart:io'; import 'dart:ui'; import 'package:flutter_chat_core/flutter_chat_core.dart'; typedef OnMessageTapCallback = void Function(Message message); typedef OnMessageSendCallback = void Function(String text); +typedef OnAudioSendCallback = void Function(File file); typedef OnAttachmentTapCallback = VoidCallback; diff --git a/packages/flutter_chat_ui/lib/src/wave_animation.dart b/packages/flutter_chat_ui/lib/src/wave_animation.dart new file mode 100644 index 00000000..4d9d1a8b --- /dev/null +++ b/packages/flutter_chat_ui/lib/src/wave_animation.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +/// A widget that displays bars based on audio input level. +/// +/// This widget is used to indicate ongoing processes like recording by +/// adjusting the height of bars according to the audio input level. +class BarAnimation extends StatelessWidget { + /// The color of the bars in the animation. + final Color color; + + /// The audio input level that affects the bar height. + final double audioLevel; + + /// Creates a [BarAnimation] widget. + const BarAnimation( + {super.key, required this.color, required this.audioLevel,}); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(32, (index) { + final barHeight = audioLevel * 16.0; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 1.0), + child: Container( + width: 4.0, + height: barHeight, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(2.0), + ), + ), + ); + }), + ); + } +} diff --git a/packages/flutter_chat_ui/pubspec.yaml b/packages/flutter_chat_ui/pubspec.yaml index 0ca1541c..e64c6007 100644 --- a/packages/flutter_chat_ui/pubspec.yaml +++ b/packages/flutter_chat_ui/pubspec.yaml @@ -16,7 +16,9 @@ dependencies: flutter: sdk: flutter flutter_chat_core: ^0.0.2 + permission_handler: ^11.3.1 provider: ^6.1.2 + record: ^5.2.0 dev_dependencies: flutter_lints: ^4.0.0 diff --git a/packages/flyer_chat_audio_message/lib/src/flyer_chat_audio_message.dart b/packages/flyer_chat_audio_message/lib/src/flyer_chat_audio_message.dart index e69de29b..4925262c 100644 --- a/packages/flyer_chat_audio_message/lib/src/flyer_chat_audio_message.dart +++ b/packages/flyer_chat_audio_message/lib/src/flyer_chat_audio_message.dart @@ -0,0 +1,153 @@ +import 'dart:async'; +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_chat_core/flutter_chat_core.dart'; + +/// A widget that displays an audio message with play/pause functionality. +class FlyerChatAudioMessage extends StatefulWidget { + /// The audio message to be played. + final AudioMessage message; + + /// The border radius of the audio message container. + final BorderRadiusGeometry? borderRadius; + + /// The constraints for the audio message container. + final BoxConstraints? constraints; + + /// Creates a [FlyerChatAudioMessage] widget. + const FlyerChatAudioMessage({ + super.key, + required this.message, + this.borderRadius = const BorderRadius.all(Radius.circular(12)), + this.constraints = const BoxConstraints(maxWidth: 300, minHeight: 50), + }); + + @override + FlyerChatAudioMessageState createState() => FlyerChatAudioMessageState(); +} + +class FlyerChatAudioMessageState extends State { + late final AudioPlayer _player; + PlayerState? _playerState; + Duration? _currentPosition; + Duration? _totalDuration; + + StreamSubscription? _durationSubscription; + StreamSubscription? _positionSubscription; + StreamSubscription? _playerCompleteSubscription; + StreamSubscription? _playerStateChangeSubscription; + + bool get _isPlaying => _playerState == PlayerState.playing; + + @override + void initState() { + super.initState(); + _player = AudioPlayer(); + _player.setReleaseMode(ReleaseMode.stop); + _initStreams(); + } + + @override + void dispose() { + _durationSubscription?.cancel(); + _positionSubscription?.cancel(); + _playerCompleteSubscription?.cancel(); + _playerStateChangeSubscription?.cancel(); + _player.dispose(); + super.dispose(); + } + + Future _togglePlayPause() async { + try { + if (_isPlaying) { + await _player.pause(); + } else { + await _player.setSource(AssetSource(widget.message.audioFile)); + await _player.resume(); + } + } on AudioPlayerException catch (e) { + _showErrorDialog('Audio Player Error', e.toString()); + } catch (e) { + _showErrorDialog('Error', 'An unexpected error occurred. $e'); + } + } + + void _showErrorDialog(String title, String message) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('OK'), + ), + ], + ), + ); + } + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: widget.borderRadius ?? BorderRadius.zero, + child: Container( + constraints: widget.constraints, + color: Colors.grey[200], + child: Row( + children: [ + IconButton( + icon: Icon( + _isPlaying ? Icons.pause : Icons.play_arrow, + size: 32, + ), + onPressed: _togglePlayPause, + ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Slider( + value: _currentPosition?.inSeconds.toDouble() ?? 0.0, + max: _totalDuration?.inSeconds.toDouble() ?? 1.0, + onChanged: (value) async { + final position = Duration(seconds: value.toInt()); + await _player.seek(position); + setState(() { + _currentPosition = position; + }); + }, + ), + ], + ), + ), + ], + ), + ), + ); + } + + void _initStreams() { + _durationSubscription = _player.onDurationChanged.listen((duration) { + setState(() => _totalDuration = duration); + }); + + _positionSubscription = _player.onPositionChanged.listen((position) { + setState(() => _currentPosition = position); + }); + + _playerCompleteSubscription = _player.onPlayerComplete.listen((event) { + setState(() { + _playerState = PlayerState.stopped; + _currentPosition = Duration.zero; + }); + }); + + _playerStateChangeSubscription = _player.onPlayerStateChanged.listen((state) { + setState(() { + _playerState = state; + }); + }); + } +} diff --git a/packages/flyer_chat_audio_message/pubspec.yaml b/packages/flyer_chat_audio_message/pubspec.yaml index fdac7885..4b5119a5 100644 --- a/packages/flyer_chat_audio_message/pubspec.yaml +++ b/packages/flyer_chat_audio_message/pubspec.yaml @@ -10,8 +10,10 @@ environment: flutter: ">=3.16.0" dependencies: + audioplayers: ^6.1.0 flutter: sdk: flutter + flutter_chat_core: ^0.0.2 dev_dependencies: flutter_lints: ^4.0.0 diff --git a/packages/flyer_chat_custom_message/lib/src/flyer_chat_custom_message.dart b/packages/flyer_chat_custom_message/lib/src/flyer_chat_custom_message.dart index e69de29b..8b137891 100644 --- a/packages/flyer_chat_custom_message/lib/src/flyer_chat_custom_message.dart +++ b/packages/flyer_chat_custom_message/lib/src/flyer_chat_custom_message.dart @@ -0,0 +1 @@ + diff --git a/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart b/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart index e69de29b..8b137891 100644 --- a/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart +++ b/packages/flyer_chat_file_message/lib/src/flyer_chat_file_message.dart @@ -0,0 +1 @@ + diff --git a/packages/flyer_chat_image_message/lib/src/flyer_chat_image_message.dart b/packages/flyer_chat_image_message/lib/src/flyer_chat_image_message.dart index 24d6610b..67f0d33e 100644 --- a/packages/flyer_chat_image_message/lib/src/flyer_chat_image_message.dart +++ b/packages/flyer_chat_image_message/lib/src/flyer_chat_image_message.dart @@ -6,8 +6,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart'; import 'package:image/image.dart' show encodeJpg; import 'package:provider/provider.dart'; -import 'package:thumbhash/thumbhash.dart' - show rgbaToBmp, thumbHashToApproximateAspectRatio, thumbHashToRGBA; +import 'package:thumbhash/thumbhash.dart' show rgbaToBmp, thumbHashToApproximateAspectRatio, thumbHashToRGBA; import 'custom_network_image.dart'; import 'preload_image_provider.dart'; @@ -28,8 +27,7 @@ class FlyerChatImageMessage extends StatefulWidget { FlyerChatImageMessageState createState() => FlyerChatImageMessageState(); } -class FlyerChatImageMessageState extends State - with TickerProviderStateMixin { +class FlyerChatImageMessageState extends State with TickerProviderStateMixin { late ChatController _chatController; late CustomNetworkImage _customNetworkImage; late double _aspectRatio; @@ -42,8 +40,7 @@ class FlyerChatImageMessageState extends State if (widget.message.width != null && widget.message.height != null) { _aspectRatio = widget.message.width! / widget.message.height!; } else if (widget.message.thumbhash != null) { - final thumbhashBytes = - base64.decode(base64.normalize(widget.message.thumbhash!)); + final thumbhashBytes = base64.decode(base64.normalize(widget.message.thumbhash!)); _aspectRatio = thumbHashToApproximateAspectRatio(thumbhashBytes); @@ -84,10 +81,8 @@ class FlyerChatImageMessageState extends State @override Widget build(BuildContext context) { - final backgroundColor = - context.select((ChatTheme theme) => theme.backgroundColor); - final imagePlaceholderColor = - context.select((ChatTheme theme) => theme.imagePlaceholderColor); + final backgroundColor = context.select((ChatTheme theme) => theme.backgroundColor); + final imagePlaceholderColor = context.select((ChatTheme theme) => theme.imagePlaceholderColor); return ClipRRect( borderRadius: widget.borderRadius ?? BorderRadius.zero, @@ -118,8 +113,7 @@ class FlyerChatImageMessageState extends State child: CircularProgressIndicator( color: backgroundColor.withOpacity(0.5), value: loadingProgress.expectedTotalBytes != null - ? loadingProgress.cumulativeBytesLoaded / - loadingProgress.expectedTotalBytes! + ? loadingProgress.cumulativeBytesLoaded / loadingProgress.expectedTotalBytes! : null, ), ); diff --git a/packages/flyer_chat_location_message/lib/src/flyer_chat_location_message.dart b/packages/flyer_chat_location_message/lib/src/flyer_chat_location_message.dart index e69de29b..8b137891 100644 --- a/packages/flyer_chat_location_message/lib/src/flyer_chat_location_message.dart +++ b/packages/flyer_chat_location_message/lib/src/flyer_chat_location_message.dart @@ -0,0 +1 @@ + diff --git a/packages/flyer_chat_system_message/lib/src/flyer_chat_system_message.dart b/packages/flyer_chat_system_message/lib/src/flyer_chat_system_message.dart index e69de29b..8b137891 100644 --- a/packages/flyer_chat_system_message/lib/src/flyer_chat_system_message.dart +++ b/packages/flyer_chat_system_message/lib/src/flyer_chat_system_message.dart @@ -0,0 +1 @@ + diff --git a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart index e69de29b..8b137891 100644 --- a/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart +++ b/packages/flyer_chat_video_message/lib/src/flyer_chat_video_message.dart @@ -0,0 +1 @@ +