From 4d2354d36a0a536892f1c0ee1d1ab31f2c20a01c Mon Sep 17 00:00:00 2001 From: sukhman-sukh Date: Tue, 23 Jan 2024 22:39:34 +0530 Subject: [PATCH 01/11] feat: Audio message recorder and player functionality implemented --- .../chat/widgets/audio_message_widget.dart | 181 ++++++++++++++++++ .../screens/home/tabs/chat/widgets/chat.dart | 132 ++++++++++++- .../home/tabs/chat/widgets/custom_input.dart | 14 ++ .../home/tabs/chat/widgets/file_sharing.dart | 5 +- qaul_ui/pubspec.lock | 120 ++++++++++++ qaul_ui/pubspec.yaml | 2 + 6 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 qaul_ui/lib/screens/home/tabs/chat/widgets/audio_message_widget.dart diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_message_widget.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_message_widget.dart new file mode 100644 index 000000000..02cfe05f5 --- /dev/null +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_message_widget.dart @@ -0,0 +1,181 @@ +part of 'chat.dart'; + +class AudioMessageWidget extends StatefulWidget { + const AudioMessageWidget({ + Key? key, + required this.message, + required this.messageWidth, + this.isDefaultUser = false, + }) : super(key: key); + + final types.AudioMessage message; + + final int messageWidth; + + final bool isDefaultUser; + + @override + State createState() => _AudioMessageWidgetState(); +} + +class _AudioMessageWidgetState extends State { + double get _controlSize => (widget.messageWidth.toDouble()) / 10; + + final _audioPlayer = AudioPlayer()..setReleaseMode(ReleaseMode.stop); + + Duration? _position; + + Duration? _duration; + + String? audioPath; + + late StreamSubscription _playerStateChangedSubscription; + + late StreamSubscription _durationChangedSubscription; + + late StreamSubscription _positionChangedSubscription; + + @override + void initState() { + _playerStateChangedSubscription = + _audioPlayer.onPlayerComplete.listen((state) async { + await stop(); + }); + _positionChangedSubscription = _audioPlayer.onPositionChanged.listen( + (position) => setState(() { + _position = position; + }), + ); + _durationChangedSubscription = _audioPlayer.onDurationChanged.listen( + (duration) => setState(() { + _duration = duration; + }), + ); + + audioPath = widget.message.uri; + _getDuration(); + _audioPlayer.setSource(_source); + super.initState(); + } + + @override + void dispose() { + _playerStateChangedSubscription.cancel(); + _positionChangedSubscription.cancel(); + _durationChangedSubscription.cancel(); + _audioPlayer.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: _controlSize / 2), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _buildControl(), + _buildSlider(constraints.maxWidth), + ], + ), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SizedBox(width: widget.messageWidth.toDouble() / 4), + Text(':: ${_duration?.inSeconds ?? 0.0} Seconds'), + ], + ), + ], + ); + }, + ); + } + + Widget _buildControl() { + Icon icon; + Color color; + + if (_audioPlayer.state == PlayerState.playing) { + icon = const Icon(Icons.pause, color: Colors.red, size: 30); + color = Colors.red.withOpacity(0.1); + } else { + final theme = Theme.of(context); + icon = Icon(Icons.play_arrow, color: theme.primaryColor, size: 30); + color = theme.primaryColor.withOpacity(0.1); + } + + return ClipOval( + child: Material( + color: color, + child: InkWell( + child: + SizedBox(width: _controlSize, height: _controlSize, child: icon), + onTap: () { + if (_audioPlayer.state == PlayerState.playing) { + pause(); + } else { + play(); + } + }, + ), + ), + ); + } + + Widget _buildSlider(double widgetWidth) { + bool canSetValue = false; + final duration = _duration; + final position = _position; + + if (duration != null && position != null) { + canSetValue = position.inMilliseconds >= 0; + canSetValue &= position.inMilliseconds < duration.inMilliseconds; + } + + double width = widgetWidth - _controlSize; + + return SizedBox( + width: width, + child: Slider( + activeColor: Theme.of(context).primaryColor, + inactiveColor: Theme.of(context).colorScheme.secondary, + onChanged: (v) { + if (duration != null) { + final position = v * duration.inMilliseconds; + _audioPlayer.seek(Duration(milliseconds: position.round())); + } + }, + value: canSetValue && duration != null && position != null + ? position.inMilliseconds / duration.inMilliseconds + : 0.0, + ), + ); + } + + Future play() => _audioPlayer.play(_source); + + Future pause() async { + await _audioPlayer.pause(); + setState(() {}); + } + + Future stop() async { + await _audioPlayer.stop(); + setState(() {}); + } + + Future _getDuration() async { + await _audioPlayer.setSourceUrl(audioPath!); + final duration = await _audioPlayer.getDuration(); + _duration = duration; + } + + Source get _source => + kIsWeb ? UrlSource(audioPath!) : DeviceFileSource(audioPath!); +} diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart index 79579756e..efdb66ee5 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart @@ -1,10 +1,13 @@ +import 'dart:async'; import 'dart:io'; +import 'package:audioplayers/audioplayers.dart'; import 'package:better_open_file/better_open_file.dart'; import 'package:bubble/bubble.dart'; import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; import 'package:filesize/filesize.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart' as types; @@ -21,8 +24,10 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; -import 'package:path/path.dart' hide context, Context; +import 'package:path/path.dart' as p hide context, Context; +import 'package:path_provider/path_provider.dart'; import 'package:qaul_rpc/qaul_rpc.dart'; +import 'package:record/record.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:utils/utils.dart'; @@ -33,14 +38,11 @@ import '../../../../../widgets/widgets.dart'; import '../../tab.dart'; import 'conditional/conditional.dart'; +part 'audio_message_widget.dart'; part 'custom_input.dart'; - part 'file_message_widget.dart'; - part 'file_sharing.dart'; - part 'group_settings.dart'; - part 'image_message_widget.dart'; typedef OnSendPressed = void Function(String rawText); @@ -95,7 +97,8 @@ class ChatScreen extends StatefulHookConsumerWidget { ConsumerState createState() => _ChatScreenState(); } -class _ChatScreenState extends ConsumerState { +class _ChatScreenState extends ConsumerState + with AudioRecorderMixin { ChatRoom get room => widget.room; User get user => widget.user; @@ -104,6 +107,13 @@ class _ChatScreenState extends ConsumerState { final Map _overflowMenuOptions = {}; + late AudioRecorder audioRecord; + + late AudioPlayer audioPlayer; + + bool isRecording = false; + + late String audioPath; void _handleClick(String value) { switch (value) { case 'groupSettings': @@ -126,6 +136,8 @@ class _ChatScreenState extends ConsumerState { assert(otherUser != null || room.isGroupChatRoom, 'Must either be a group chat or contain another user'); _scheduleUpdateCurrentOpenChat(); + audioPlayer = AudioPlayer(); + audioRecord = AudioRecorder(); } @override @@ -142,6 +154,13 @@ class _ChatScreenState extends ConsumerState { _scheduleUpdateCurrentOpenChat(); } + @override + void dispose() { + super.dispose(); + audioRecord.dispose(); + audioPlayer.dispose(); + } + @override Widget build(BuildContext context) { final room = ref.watch(currentOpenChatRoom); @@ -227,7 +246,8 @@ class _ChatScreenState extends ConsumerState { sendButtonVisibilityMode: SendButtonVisibilityMode.always, ), avatarBuilder: (id) { - var user = room.members.firstWhereOrNull((u) => id.id == u.idBase58); + var user = + room.members.firstWhereOrNull((u) => id.id == u.idBase58); if (user == null) return const SizedBox(); return QaulAvatar.small(user: user, badgeEnabled: false); }, @@ -235,6 +255,7 @@ class _ChatScreenState extends ConsumerState { bubbleBuilder: _bubbleBuilder, customBottomWidget: _CustomInput( isDisabled: room.status != ChatRoomStatus.active, + isRecording: isRecording, disabledMessage: room.status != ChatRoomStatus.inviteAccepted ? null : 'Please wait for the admin to confirm your acceptance to send messages', @@ -289,6 +310,38 @@ class _ChatScreenState extends ConsumerState { if (result != null) { File file = File(result.path); + // ignore: use_build_context_synchronously + if (!context.mounted) return; + showModalBottomSheet( + context: context, + builder: (_) { + return _SendFileDialog( + file, + room: room, + partialMessage: text?.text, + onSendPressed: (description) { + final worker = ref.read(qaulWorkerProvider); + worker.sendFile( + pathName: file.path, + conversationId: room.conversationId, + description: description.text, + ); + }, + ); + }, + ); + } + }, + onRecordAudioPressed: !(Platform.isAndroid || Platform.isIOS) + ? null + : (room.messages?.isEmpty ?? true) + ? null + : ({types.PartialText? text}) async { + (isRecording) + ? await stopRecording() + : await startRecording(); + if (!isRecording) { + File file = File(audioPath); // ignore: use_build_context_synchronously if (!context.mounted) return; showModalBottomSheet( @@ -386,6 +439,13 @@ class _ChatScreenState extends ConsumerState { isDefaultUser: message.author.id == user.idBase58, ); }, + audioMessageBuilder: (message, {required int messageWidth}) { + return AudioMessageWidget( + message: message, + messageWidth: messageWidth, + isDefaultUser: message.author.id == user.idBase58, + ); + }, theme: DefaultChatTheme( userAvatarNameColors: [ colorGenerationStrategy(otherUser?.idBase58 ?? room.idBase58), @@ -477,6 +537,49 @@ class _ChatScreenState extends ConsumerState { _overflowMenuOptions.clear(); } } + + Future startRecording() async { + try { + if (await audioRecord.hasPermission()) { + const encoder = AudioEncoder.aacLc; + setState(() { + isRecording = true; + }); + const config = RecordConfig(encoder: encoder, numChannels: 1); + await recordFile(audioRecord, config); + } + } catch (e) { + return; + } + } + + Future stopRecording() async { + try { + final path = await audioRecord.stop(); + setState(() { + isRecording = false; + audioPath = path!; + }); + } catch (e) { + return; + } + } +} + +mixin AudioRecorderMixin { + Future recordFile(AudioRecorder recorder, RecordConfig config) async { + final path = await _getPath(); + + await recorder.start(config, path: path); + } + + Future _getPath() async { + final dir = await getApplicationDocumentsDirectory(); + return p.join( + dir.path, + 'audio_${DateTime.now().millisecondsSinceEpoch}.m4a', + ); + } } extension _MessageExtension on Message { @@ -528,6 +631,21 @@ extension _MessageExtension on Message { 'messageState': status.toJson(), }, ); + } else if (mimeStr != null && RegExp('audio/.*').hasMatch(mimeStr)) { + return types.AudioMessage( + id: messageIdBase58, + duration: const Duration(seconds: 100), + author: author.toInternalUser(), + createdAt: receivedAt.millisecondsSinceEpoch, + status: mappedState, + uri: filePath, + size: (content as FileShareContent).size, + name: (content as FileShareContent).fileName, + metadata: { + 'description': (content as FileShareContent).description, + 'messageState': status.toJson(), + }, + ); } return types.FileMessage( diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart index ddaa27c6b..9aa667862 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart @@ -21,9 +21,11 @@ class _CustomInput extends StatefulWidget { required this.hintText, this.onAttachmentPressed, this.onPickImagePressed, + this.onRecordAudioPressed, this.initialText, this.disabledMessage, this.isDisabled = false, + this.isRecording = false, }) : super(key: key); final void Function(types.PartialText) onSendPressed; @@ -32,6 +34,8 @@ class _CustomInput extends StatefulWidget { final Function({types.PartialText? text})? onPickImagePressed; + final Function({types.PartialText? text})? onRecordAudioPressed; + final SendButtonVisibilityMode sendButtonVisibilityMode; final String? initialText; @@ -42,6 +46,8 @@ class _CustomInput extends StatefulWidget { final String hintText; + final bool isRecording; + @override _CustomInputState createState() => _CustomInputState(); } @@ -179,6 +185,14 @@ class _CustomInputState extends State<_CustomInput> { onPressed: () => _sendFilePressed( widget.onPickImagePressed), ), + if (widget.onRecordAudioPressed != null) + _AttachmentButton( + icon: (widget.isRecording) + ? Icons.stop_circle + : Icons.mic_none, + onPressed: () => _sendFilePressed( + widget.onRecordAudioPressed), + ), ], ), ), diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/file_sharing.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/file_sharing.dart index 402031563..02d296bdf 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/file_sharing.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/file_sharing.dart @@ -30,7 +30,7 @@ class _SendFileDialog extends HookConsumerWidget { children: [ Text(filesize(file.lengthSync())), Text( - basename(file.path), + p.basename(file.path), maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -47,8 +47,7 @@ class _SendFileDialog extends HookConsumerWidget { const SizedBox(height: 8), _CustomInput( initialText: partialMessage, - hintText: AppLocalizations.of(context)! - .chatEmptyMessageHint, + hintText: AppLocalizations.of(context)!.chatEmptyMessageHint, onSendPressed: (desc) { final worker = ref.read(qaulWorkerProvider); worker.sendFile( diff --git a/qaul_ui/pubspec.lock b/qaul_ui/pubspec.lock index bda21da1a..a4912557d 100644 --- a/qaul_ui/pubspec.lock +++ b/qaul_ui/pubspec.lock @@ -49,6 +49,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + sha256: c05c6147124cd63e725e861335a8b4d57300b80e6e92cea7c145c739223bbaef + url: "https://pub.dev" + source: hosted + version: "5.2.1" + audioplayers_android: + dependency: transitive + description: + name: audioplayers_android + sha256: b00e1a0e11365d88576320ec2d8c192bc21f1afb6c0e5995d1c57ae63156acb5 + url: "https://pub.dev" + source: hosted + version: "4.0.3" + audioplayers_darwin: + dependency: transitive + description: + name: audioplayers_darwin + sha256: "3034e99a6df8d101da0f5082dcca0a2a99db62ab1d4ddb3277bed3f6f81afe08" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + audioplayers_linux: + dependency: transitive + description: + name: audioplayers_linux + sha256: "60787e73fefc4d2e0b9c02c69885402177e818e4e27ef087074cf27c02246c9e" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + audioplayers_platform_interface: + dependency: transitive + description: + name: audioplayers_platform_interface + sha256: "365c547f1bb9e77d94dd1687903a668d8f7ac3409e48e6e6a3668a1ac2982adb" + url: "https://pub.dev" + source: hosted + version: "6.1.0" + audioplayers_web: + dependency: transitive + description: + name: audioplayers_web + sha256: "22cd0173e54d92bd9b2c80b1204eb1eb159ece87475ab58c9788a70ec43c2a62" + url: "https://pub.dev" + source: hosted + version: "4.1.0" + audioplayers_windows: + dependency: transitive + description: + name: audioplayers_windows + sha256: "9536812c9103563644ada2ef45ae523806b0745f7a78e89d1b5fb1951de90e1a" + url: "https://pub.dev" + source: hosted + version: "3.1.0" badges: dependency: "direct main" description: @@ -1106,6 +1162,62 @@ packages: url: "https://pub.dev" source: hosted version: "4.1.0" + record: + dependency: "direct main" + description: + name: record + sha256: "5c8e12c692a4800b33f5f8b6c821ea083b12bfdbd031b36ba9322c40a4eeecc9" + url: "https://pub.dev" + source: hosted + version: "5.0.4" + record_android: + dependency: transitive + description: + name: record_android + sha256: "805ecaa232a671aff2ee9ec4730ef6addb97c548d2db6b1fbd5197f1d4f47a5a" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + record_darwin: + dependency: transitive + description: + name: record_darwin + sha256: ee8cb1bb1712d7ce38140ecabe70e5c286c02f05296d66043bee865ace7eb1b9 + url: "https://pub.dev" + source: hosted + version: "1.0.1" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: "7d0e70cd51635128fe9d37d89bafd6011d7cbba9af8dc323079ae60f23546aef" + url: "https://pub.dev" + source: hosted + version: "0.7.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "3a4b56e94ecd2a0b2b43eb1fa6f94c5b8484334f5d38ef43959c4bf97fb374cf" + url: "https://pub.dev" + source: hosted + version: "1.0.2" + record_web: + dependency: transitive + description: + name: record_web + sha256: "24847cdbcf999f7a5762170792f622ac844858766becd0f2370ec8ae22f7526e" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "39998b3ea7d8d28b04159d82220e6e5e32a7c357c6fb2794f5736beea272f6c3" + url: "https://pub.dev" + source: hosted + version: "1.0.2" responsive_framework: dependency: "direct main" description: @@ -1319,6 +1431,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.3.1" + synchronized: + dependency: transitive + description: + name: synchronized + sha256: "539ef412b170d65ecdafd780f924e5be3f60032a1128df156adad6c5b373d558" + url: "https://pub.dev" + source: hosted + version: "3.1.0+1" term_glyph: dependency: transitive description: diff --git a/qaul_ui/pubspec.yaml b/qaul_ui/pubspec.yaml index 17531db55..c9ff3125f 100644 --- a/qaul_ui/pubspec.yaml +++ b/qaul_ui/pubspec.yaml @@ -62,6 +62,8 @@ dependencies: uni_links: ^0.5.1 url_launcher: ^6.1.14 utils: { "path": "packages/utils" } + record: ^5.0.4 + audioplayers: ^5.2.1 dev_dependencies: flutter_test: From 05e5515d25d99a91f6665f86a474fa0e888530c7 Mon Sep 17 00:00:00 2001 From: brenodt <54450520+brenodt@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:23:14 -0300 Subject: [PATCH 02/11] chore: update macos configuration to allow for the sending of audio messages --- qaul_ui/macos/Podfile | 2 +- qaul_ui/macos/Podfile.lock | 15 ++++++++++++++- qaul_ui/macos/Runner.xcodeproj/project.pbxproj | 14 +++++++------- .../xcshareddata/xcschemes/Runner.xcscheme | 2 +- qaul_ui/macos/Runner/DebugProfile.entitlements | 6 ++++-- qaul_ui/macos/Runner/Info.plist | 2 ++ qaul_ui/macos/Runner/Release.entitlements | 2 ++ 7 files changed, 31 insertions(+), 12 deletions(-) diff --git a/qaul_ui/macos/Podfile b/qaul_ui/macos/Podfile index 049abe295..9ec46f8cd 100644 --- a/qaul_ui/macos/Podfile +++ b/qaul_ui/macos/Podfile @@ -1,4 +1,4 @@ -platform :osx, '10.14' +platform :osx, '10.15' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/qaul_ui/macos/Podfile.lock b/qaul_ui/macos/Podfile.lock index 7045780c6..edb3b8c95 100644 --- a/qaul_ui/macos/Podfile.lock +++ b/qaul_ui/macos/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - audioplayers_darwin (0.0.1): + - FlutterMacOS - device_info_plus (0.0.1): - FlutterMacOS - file_selector_macos (0.0.1): @@ -13,6 +15,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - record_darwin (1.0.0): + - Flutter + - FlutterMacOS - shared_preferences_foundation (0.0.1): - Flutter - FlutterMacOS @@ -20,6 +25,7 @@ PODS: - FlutterMacOS DEPENDENCIES: + - audioplayers_darwin (from `Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - file_selector_macos (from `Flutter/ephemeral/.symlinks/plugins/file_selector_macos/macos`) - flutter_app_badger (from `Flutter/ephemeral/.symlinks/plugins/flutter_app_badger/macos`) @@ -27,10 +33,13 @@ DEPENDENCIES: - FlutterMacOS (from `Flutter/ephemeral`) - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - record_darwin (from `Flutter/ephemeral/.symlinks/plugins/record_darwin/macos`) - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) EXTERNAL SOURCES: + audioplayers_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/audioplayers_darwin/macos device_info_plus: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos file_selector_macos: @@ -45,12 +54,15 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos path_provider_foundation: :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + record_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/record_darwin/macos shared_preferences_foundation: :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin url_launcher_macos: :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos SPEC CHECKSUMS: + audioplayers_darwin: dcad41de4fbd0099cb3749f7ab3b0cb8f70b810c device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f file_selector_macos: 468fb6b81fac7c0e88d71317f3eec34c3b008ff9 flutter_app_badger: 55a64b179f8438e89d574320c77b306e327a1730 @@ -58,9 +70,10 @@ SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + record_darwin: 1f6619f2abac4d1ca91d3eeab038c980d76f1517 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 -PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7 +PODFILE CHECKSUM: 0d3963a09fc94f580682bd88480486da345dc3f0 COCOAPODS: 1.15.2 diff --git a/qaul_ui/macos/Runner.xcodeproj/project.pbxproj b/qaul_ui/macos/Runner.xcodeproj/project.pbxproj index bd1a7f49f..eb4e60bd0 100644 --- a/qaul_ui/macos/Runner.xcodeproj/project.pbxproj +++ b/qaul_ui/macos/Runner.xcodeproj/project.pbxproj @@ -209,7 +209,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { 33CC10EC2044A3C60003C045 = { @@ -440,8 +440,8 @@ "$(inherited)", "$(PROJECT_DIR)", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 2.0.0-beta.17; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MARKETING_VERSION = "2.0.0-beta.17"; PRODUCT_BUNDLE_IDENTIFIER = net.qaul.app; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; @@ -576,8 +576,8 @@ "$(inherited)", "$(PROJECT_DIR)", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 2.0.0-beta.17; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MARKETING_VERSION = "2.0.0-beta.17"; PRODUCT_BUNDLE_IDENTIFIER = net.qaul.app; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -607,8 +607,8 @@ "$(inherited)", "$(PROJECT_DIR)", ); - MACOSX_DEPLOYMENT_TARGET = 10.14; - MARKETING_VERSION = 2.0.0-beta.17; + MACOSX_DEPLOYMENT_TARGET = 10.15; + MARKETING_VERSION = "2.0.0-beta.17"; PRODUCT_BUNDLE_IDENTIFIER = net.qaul.app; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; diff --git a/qaul_ui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/qaul_ui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 9e1de3a16..016d8f6de 100644 --- a/qaul_ui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/qaul_ui/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ com.apple.security.cs.allow-jit - com.apple.security.network.server + com.apple.security.device.audio-input com.apple.security.network.client - + + com.apple.security.network.server + diff --git a/qaul_ui/macos/Runner/Info.plist b/qaul_ui/macos/Runner/Info.plist index e5bc2a248..350a87483 100644 --- a/qaul_ui/macos/Runner/Info.plist +++ b/qaul_ui/macos/Runner/Info.plist @@ -30,5 +30,7 @@ MainMenu NSPrincipalClass NSApplication + NSMicrophoneUsageDescription + You can send audio messages to your peers. For that, please grant access to the application. diff --git a/qaul_ui/macos/Runner/Release.entitlements b/qaul_ui/macos/Runner/Release.entitlements index 7a2230dc3..9f3c0e8c3 100644 --- a/qaul_ui/macos/Runner/Release.entitlements +++ b/qaul_ui/macos/Runner/Release.entitlements @@ -4,6 +4,8 @@ com.apple.security.app-sandbox + com.apple.security.device.audio-input + com.apple.security.network.client com.apple.security.network.server From bc4e1fa18f713a15f1aa5ced1897d57c8ff86950 Mon Sep 17 00:00:00 2001 From: brenodt <54450520+brenodt@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:24:04 -0300 Subject: [PATCH 03/11] chore: update android configuration to allow for the sending of audio messages --- qaul_ui/android/app/build.gradle | 2 +- qaul_ui/android/app/src/main/AndroidManifest.xml | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/qaul_ui/android/app/build.gradle b/qaul_ui/android/app/build.gradle index 914204e25..e119e43d6 100644 --- a/qaul_ui/android/app/build.gradle +++ b/qaul_ui/android/app/build.gradle @@ -36,7 +36,7 @@ group = "net.qaul.app" version = flutterVersionName android { - compileSdkVersion 33 + compileSdkVersion 34 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 diff --git a/qaul_ui/android/app/src/main/AndroidManifest.xml b/qaul_ui/android/app/src/main/AndroidManifest.xml index 199e74672..9e5a55034 100644 --- a/qaul_ui/android/app/src/main/AndroidManifest.xml +++ b/qaul_ui/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,11 @@ + + + + + From ed8fe293bc834ff4dabc07f025af23b1a5b3cf8e Mon Sep 17 00:00:00 2001 From: brenodt <54450520+brenodt@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:24:21 -0300 Subject: [PATCH 04/11] chore: update ios configuration to allow for the sending of audio messages --- qaul_ui/ios/Podfile.lock | 15 ++++++++++++++- qaul_ui/ios/Runner/Info.plist | 2 ++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/qaul_ui/ios/Podfile.lock b/qaul_ui/ios/Podfile.lock index 81dbb16d6..ec226b35e 100644 --- a/qaul_ui/ios/Podfile.lock +++ b/qaul_ui/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - audioplayers_darwin (0.0.1): + - Flutter - better_open_file (0.0.1): - Flutter - device_info_plus (0.0.1): @@ -53,6 +55,9 @@ PODS: - path_provider_foundation (0.0.1): - Flutter - FlutterMacOS + - record_darwin (1.0.0): + - Flutter + - FlutterMacOS - SDWebImage (5.15.2): - SDWebImage/Core (= 5.15.2) - SDWebImage/Core (5.15.2) @@ -66,6 +71,7 @@ PODS: - Flutter DEPENDENCIES: + - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/ios`) - better_open_file (from `.symlinks/plugins/better_open_file/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -77,6 +83,7 @@ DEPENDENCIES: - integration_test (from `.symlinks/plugins/integration_test/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - record_darwin (from `.symlinks/plugins/record_darwin/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - uni_links (from `.symlinks/plugins/uni_links/ios`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) @@ -89,6 +96,8 @@ SPEC REPOS: - SwiftyGif EXTERNAL SOURCES: + audioplayers_darwin: + :path: ".symlinks/plugins/audioplayers_darwin/ios" better_open_file: :path: ".symlinks/plugins/better_open_file/ios" device_info_plus: @@ -111,6 +120,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/package_info_plus/ios" path_provider_foundation: :path: ".symlinks/plugins/path_provider_foundation/darwin" + record_darwin: + :path: ".symlinks/plugins/record_darwin/ios" shared_preferences_foundation: :path: ".symlinks/plugins/shared_preferences_foundation/darwin" uni_links: @@ -119,6 +130,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/url_launcher_ios/ios" SPEC CHECKSUMS: + audioplayers_darwin: 877d9a4d06331c5c374595e46e16453ac7eafa40 better_open_file: 03cf320415d4d3f46b6e00adc4a567d76c1a399d device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6 DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac @@ -132,6 +144,7 @@ SPEC CHECKSUMS: integration_test: 13825b8a9334a850581300559b8839134b124670 package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + record_darwin: 1f6619f2abac4d1ca91d3eeab038c980d76f1517 SDWebImage: 8ab87d4b3e5cc4927bd47f78db6ceb0b94442577 shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f @@ -140,4 +153,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: c4c93c5f6502fe2754f48404d3594bf779584011 -COCOAPODS: 1.14.3 +COCOAPODS: 1.15.2 diff --git a/qaul_ui/ios/Runner/Info.plist b/qaul_ui/ios/Runner/Info.plist index 4cc38798d..426846bae 100644 --- a/qaul_ui/ios/Runner/Info.plist +++ b/qaul_ui/ios/Runner/Info.plist @@ -66,5 +66,7 @@ You can send photos as attachments to your peers. For that, please grant access to the application. NSCameraUsageDescription You can take pictures and send them as attachments to your peers. For that, please grant access to the application. + NSMicrophoneUsageDescription + You can send audio messages to your peers. For that, please grant access to the application. From acb45b93ad26ff9e50c11e9b43b78a7a0277a678 Mon Sep 17 00:00:00 2001 From: brenodt <54450520+brenodt@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:25:36 -0300 Subject: [PATCH 05/11] refactor: update audio message widget ui --- qaul_ui/lib/qaul_app.dart | 8 ++ .../chat/widgets/audio_message_widget.dart | 121 +++++++++--------- 2 files changed, 69 insertions(+), 60 deletions(-) diff --git a/qaul_ui/lib/qaul_app.dart b/qaul_ui/lib/qaul_app.dart index f0d05029a..9b3bc8783 100644 --- a/qaul_ui/lib/qaul_app.dart +++ b/qaul_ui/lib/qaul_app.dart @@ -21,6 +21,10 @@ class QaulApp extends PlatformAwareBuilder { return AdaptiveTheme( light: ThemeData( brightness: Brightness.light, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.lightBlue, + brightness: Brightness.light, + ), primarySwatch: Colors.lightBlue, scaffoldBackgroundColor: Colors.white, navigationBarTheme: const NavigationBarThemeData( @@ -70,6 +74,10 @@ class QaulApp extends PlatformAwareBuilder { ), dark: ThemeData( brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: Colors.lightBlue, + brightness: Brightness.dark, + ), primarySwatch: Colors.lightBlue, visualDensity: VisualDensity.adaptivePlatformDensity, iconTheme: const IconThemeData(color: Colors.white), diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_message_widget.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_message_widget.dart index 02cfe05f5..f1f56e6ec 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_message_widget.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_message_widget.dart @@ -35,6 +35,12 @@ class _AudioMessageWidgetState extends State { late StreamSubscription _positionChangedSubscription; + Color get primaryColor => Theme.of(context).colorScheme.primary; + + Color get containerColor => Theme.of(context).colorScheme.primaryContainer; + + Color get backgroundColor => Theme.of(context).colorScheme.background; + @override void initState() { _playerStateChangedSubscription = @@ -69,53 +75,54 @@ class _AudioMessageWidgetState extends State { @override Widget build(BuildContext context) { - return LayoutBuilder( - builder: (context, constraints) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - SizedBox(height: _controlSize / 2), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - _buildControl(), - _buildSlider(constraints.maxWidth), - ], + final ttheme = Theme.of(context).textTheme; + + return Container( + padding: const EdgeInsetsDirectional.fromSTEB(16, 4, 8, 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: _controlSize / 2), + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + audioControls(), + Expanded(child: audioSlider()), + ], + ), + Padding( + padding: const EdgeInsetsDirectional.only(end: 16), + child: Text( + '${_duration?.inSeconds ?? 0.0} Seconds', + style: ttheme.labelLarge?.copyWith( + color: backgroundColor, + fontStyle: FontStyle.italic, + ), ), - Row( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - SizedBox(width: widget.messageWidth.toDouble() / 4), - Text(':: ${_duration?.inSeconds ?? 0.0} Seconds'), - ], - ), - ], - ); - }, + ), + ], + ), ); } - Widget _buildControl() { - Icon icon; - Color color; - - if (_audioPlayer.state == PlayerState.playing) { - icon = const Icon(Icons.pause, color: Colors.red, size: 30); - color = Colors.red.withOpacity(0.1); - } else { - final theme = Theme.of(context); - icon = Icon(Icons.play_arrow, color: theme.primaryColor, size: 30); - color = theme.primaryColor.withOpacity(0.1); - } - + Widget audioControls() { return ClipOval( child: Material( - color: color, + color: containerColor, child: InkWell( - child: - SizedBox(width: _controlSize, height: _controlSize, child: icon), + child: SizedBox( + width: _controlSize, + height: _controlSize, + child: Icon( + _audioPlayer.state == PlayerState.playing + ? Icons.pause + : Icons.play_arrow, + color: primaryColor, + ), + ), onTap: () { if (_audioPlayer.state == PlayerState.playing) { pause(); @@ -128,7 +135,7 @@ class _AudioMessageWidgetState extends State { ); } - Widget _buildSlider(double widgetWidth) { + Widget audioSlider() { bool canSetValue = false; final duration = _duration; final position = _position; @@ -138,23 +145,18 @@ class _AudioMessageWidgetState extends State { canSetValue &= position.inMilliseconds < duration.inMilliseconds; } - double width = widgetWidth - _controlSize; - - return SizedBox( - width: width, - child: Slider( - activeColor: Theme.of(context).primaryColor, - inactiveColor: Theme.of(context).colorScheme.secondary, - onChanged: (v) { - if (duration != null) { - final position = v * duration.inMilliseconds; - _audioPlayer.seek(Duration(milliseconds: position.round())); - } - }, - value: canSetValue && duration != null && position != null - ? position.inMilliseconds / duration.inMilliseconds - : 0.0, - ), + return Slider( + activeColor: primaryColor, + inactiveColor: backgroundColor, + onChanged: (v) { + if (duration != null) { + final position = v * duration.inMilliseconds; + _audioPlayer.seek(Duration(milliseconds: position.round())); + } + }, + value: canSetValue && duration != null && position != null + ? position.inMilliseconds / duration.inMilliseconds + : 0.0, ); } @@ -176,6 +178,5 @@ class _AudioMessageWidgetState extends State { _duration = duration; } - Source get _source => - kIsWeb ? UrlSource(audioPath!) : DeviceFileSource(audioPath!); + Source get _source => DeviceFileSource(audioPath!); } From 9dcb813d4e77e68e27c23fded69a53ae82e1f71d Mon Sep 17 00:00:00 2001 From: brenodt <54450520+brenodt@users.noreply.github.com> Date: Tue, 12 Mar 2024 16:26:31 -0300 Subject: [PATCH 06/11] refactor: simplify code, allow macos audio messages --- .../screens/home/tabs/chat/widgets/chat.dart | 69 ++++++++++--------- .../home/tabs/chat/widgets/file_sharing.dart | 2 +- 2 files changed, 37 insertions(+), 34 deletions(-) diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart index efdb66ee5..8d5969b8d 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart @@ -7,7 +7,6 @@ import 'package:bubble/bubble.dart'; import 'package:collection/collection.dart'; import 'package:file_picker/file_picker.dart'; import 'package:filesize/filesize.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_chat_types/flutter_chat_types.dart' as types; @@ -24,7 +23,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import 'package:mime/mime.dart'; -import 'package:path/path.dart' as p hide context, Context; +import 'package:path/path.dart' hide context, Context; import 'package:path_provider/path_provider.dart'; import 'package:qaul_rpc/qaul_rpc.dart'; import 'package:record/record.dart'; @@ -39,10 +38,15 @@ import '../../tab.dart'; import 'conditional/conditional.dart'; part 'audio_message_widget.dart'; + part 'custom_input.dart'; + part 'file_message_widget.dart'; + part 'file_sharing.dart'; + part 'group_settings.dart'; + part 'image_message_widget.dart'; typedef OnSendPressed = void Function(String rawText); @@ -97,8 +101,7 @@ class ChatScreen extends StatefulHookConsumerWidget { ConsumerState createState() => _ChatScreenState(); } -class _ChatScreenState extends ConsumerState - with AudioRecorderMixin { +class _ChatScreenState extends ConsumerState { ChatRoom get room => widget.room; User get user => widget.user; @@ -107,13 +110,13 @@ class _ChatScreenState extends ConsumerState final Map _overflowMenuOptions = {}; - late AudioRecorder audioRecord; - - late AudioPlayer audioPlayer; + final audioPlayer = AudioPlayer(); + final audioRecorder = AudioRecorder(); bool isRecording = false; late String audioPath; + void _handleClick(String value) { switch (value) { case 'groupSettings': @@ -136,8 +139,6 @@ class _ChatScreenState extends ConsumerState assert(otherUser != null || room.isGroupChatRoom, 'Must either be a group chat or contain another user'); _scheduleUpdateCurrentOpenChat(); - audioPlayer = AudioPlayer(); - audioRecord = AudioRecorder(); } @override @@ -156,9 +157,9 @@ class _ChatScreenState extends ConsumerState @override void dispose() { - super.dispose(); - audioRecord.dispose(); audioPlayer.dispose(); + audioRecorder.dispose(); + super.dispose(); } @override @@ -332,7 +333,9 @@ class _ChatScreenState extends ConsumerState ); } }, - onRecordAudioPressed: !(Platform.isAndroid || Platform.isIOS) + onRecordAudioPressed: !(Platform.isAndroid || + Platform.isIOS || + Platform.isMacOS) ? null : (room.messages?.isEmpty ?? true) ? null @@ -540,22 +543,38 @@ class _ChatScreenState extends ConsumerState Future startRecording() async { try { - if (await audioRecord.hasPermission()) { - const encoder = AudioEncoder.aacLc; + if (await audioRecorder.hasPermission()) { setState(() { isRecording = true; }); - const config = RecordConfig(encoder: encoder, numChannels: 1); - await recordFile(audioRecord, config); + final path = await getNewAudioFilePath(); + await audioRecorder.start(const RecordConfig(), path: path); } } catch (e) { return; } } + Future getNewAudioFilePath() async { + final dir = (Platform.isAndroid) + ? (await getExternalStorageDirectory()) + : (await getApplicationSupportDirectory()); + + if (dir == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.genericErrorMessage), + )); + } + return ""; + } + + return join(dir.path, 'audio_${DateTime.now().millisecondsSinceEpoch}.m4a'); + } + Future stopRecording() async { try { - final path = await audioRecord.stop(); + final path = await audioRecorder.stop(); setState(() { isRecording = false; audioPath = path!; @@ -566,22 +585,6 @@ class _ChatScreenState extends ConsumerState } } -mixin AudioRecorderMixin { - Future recordFile(AudioRecorder recorder, RecordConfig config) async { - final path = await _getPath(); - - await recorder.start(config, path: path); - } - - Future _getPath() async { - final dir = await getApplicationDocumentsDirectory(); - return p.join( - dir.path, - 'audio_${DateTime.now().millisecondsSinceEpoch}.m4a', - ); - } -} - extension _MessageExtension on Message { types.Message toInternalMessage(User author, Reader read, {required AppLocalizations l10n}) { diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/file_sharing.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/file_sharing.dart index 02d296bdf..0c2262415 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/file_sharing.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/file_sharing.dart @@ -30,7 +30,7 @@ class _SendFileDialog extends HookConsumerWidget { children: [ Text(filesize(file.lengthSync())), Text( - p.basename(file.path), + basename(file.path), maxLines: 2, overflow: TextOverflow.ellipsis, ), From 26869558ce2deb12686d3cfd5846ea665b85670f Mon Sep 17 00:00:00 2001 From: brenodt <54450520+brenodt@users.noreply.github.com> Date: Tue, 2 Apr 2024 14:29:34 -0300 Subject: [PATCH 07/11] feat: improve ux by isolating the audio sending into a separate dialog --- .../tabs/chat/widgets/audio_recording.dart | 208 ++++++++++++++++++ .../screens/home/tabs/chat/widgets/chat.dart | 109 ++------- .../home/tabs/chat/widgets/custom_input.dart | 16 +- qaul_ui/lib/utils.dart | 122 ++++++++++ 4 files changed, 359 insertions(+), 96 deletions(-) create mode 100644 qaul_ui/lib/screens/home/tabs/chat/widgets/audio_recording.dart diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_recording.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_recording.dart new file mode 100644 index 000000000..806190b6a --- /dev/null +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_recording.dart @@ -0,0 +1,208 @@ +part of 'chat.dart'; + +class _RecordAudioDialog extends StatefulHookConsumerWidget { + const _RecordAudioDialog({ + Key? key, + required this.room, + required this.onSendPressed, + this.partialMessage, + }) : super(key: key); + final ChatRoom room; + final String? partialMessage; + final Function(File f, types.PartialText desc) onSendPressed; + + @override + ConsumerState<_RecordAudioDialog> createState() => _RecordAudioDialogState(); +} + +class _RecordAudioDialogState extends ConsumerState<_RecordAudioDialog> { + final audioPlayer = AudioPlayer(); + final audioRecorder = AudioRecorder(); + + bool isRecording = false; + File? file; + String? audioPath; + late ComplexTimer _timer; + int _duration = 0; + + void incrementDuration(ComplexTimer _) { + setState(() => _duration += 1); + _timer.restart(); + } + + void onStopPressed() async { + _timer.pause(); + await stopRecording(); + if (audioPath != null) { + setState(() => file = File(audioPath!)); + } + } + + void onCancelPressed() async { + if (isRecording) { + await stopRecording(); + } + file?.deleteSync(); + if (mounted) Navigator.pop(context); + } + + void onSendPressed() { + final worker = ref.read(qaulWorkerProvider); + worker.sendFile( + pathName: file!.path, + conversationId: widget.room.conversationId, + description: 'audio message'); + + Navigator.pop(context); + } + + @override + void initState() { + super.initState(); + _timer = ComplexTimer(const Duration(seconds: 1)); + _timer.onTimeout = incrementDuration; + _timer.pause(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + startRecording(); + _timer.restart(); + }); + } + + @override + void dispose() { + _timer.cancel(); + audioPlayer.dispose(); + audioRecorder.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: IconButtonFactory.close(onPressed: onCancelPressed), + ), + if (isRecording) + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.mic_rounded, + size: 28, + color: Colors.redAccent.shade100, + ), + Text( + _messageDuration, + ), + ], + ), + IconButton( + iconSize: 48, + onPressed: onStopPressed, + color: Colors.redAccent.shade100, + icon: const Icon(Icons.stop_circle_outlined), + ), + ], + ), + if (file != null) ...[ + Row( + children: [ + const SizedBox(width: 20), + const Icon(Icons.insert_drive_file_outlined, size: 40), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(filesize(file!.lengthSync())), + Text( + _messageDuration, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + OutlinedButton( + onPressed: onCancelPressed, + child: Text('Cancel'), + ), + FilledButton( + onPressed: onSendPressed, + child: Text('Send'), + ), + ], + ), + ), + ], + ], + ), + ); + } + + String get _messageDuration => Duration(seconds: _duration) + .toString() + .substring(2) + .replaceAll('.000000', ''); + + Future startRecording() async { + try { + if (await audioRecorder.hasPermission()) { + setState(() { + isRecording = true; + }); + final path = await getNewAudioFilePath(); + await audioRecorder.start(const RecordConfig(), path: path); + } + } catch (e) { + return; + } + } + + Future stopRecording() async { + try { + final path = await audioRecorder.stop(); + setState(() { + isRecording = false; + audioPath = path!; + }); + } catch (e) { + return; + } + } + + Future getNewAudioFilePath() async { + final dir = (Platform.isAndroid) + ? (await getExternalStorageDirectory()) + : (await getApplicationSupportDirectory()); + + if (dir == null) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(AppLocalizations.of(context)!.genericErrorMessage), + )); + } + return ""; + } + + return join(dir.path, 'audio_${DateTime.now().millisecondsSinceEpoch}.m4a'); + } +} diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart index 8d5969b8d..439d2b727 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart @@ -39,6 +39,8 @@ import 'conditional/conditional.dart'; part 'audio_message_widget.dart'; +part 'audio_recording.dart'; + part 'custom_input.dart'; part 'file_message_widget.dart'; @@ -110,13 +112,6 @@ class _ChatScreenState extends ConsumerState { final Map _overflowMenuOptions = {}; - final audioPlayer = AudioPlayer(); - final audioRecorder = AudioRecorder(); - - bool isRecording = false; - - late String audioPath; - void _handleClick(String value) { switch (value) { case 'groupSettings': @@ -155,13 +150,6 @@ class _ChatScreenState extends ConsumerState { _scheduleUpdateCurrentOpenChat(); } - @override - void dispose() { - audioPlayer.dispose(); - audioRecorder.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final room = ref.watch(currentOpenChatRoom); @@ -256,7 +244,6 @@ class _ChatScreenState extends ConsumerState { bubbleBuilder: _bubbleBuilder, customBottomWidget: _CustomInput( isDisabled: room.status != ChatRoomStatus.active, - isRecording: isRecording, disabledMessage: room.status != ChatRoomStatus.inviteAccepted ? null : 'Please wait for the admin to confirm your acceptance to send messages', @@ -333,39 +320,34 @@ class _ChatScreenState extends ConsumerState { ); } }, - onRecordAudioPressed: !(Platform.isAndroid || + onSendAudioPressed: !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS) ? null : (room.messages?.isEmpty ?? true) ? null : ({types.PartialText? text}) async { - (isRecording) - ? await stopRecording() - : await startRecording(); - if (!isRecording) { - File file = File(audioPath); - // ignore: use_build_context_synchronously - if (!context.mounted) return; - showModalBottomSheet( - context: context, - builder: (_) { - return _SendFileDialog( - file, - room: room, - partialMessage: text?.text, - onSendPressed: (description) { - final worker = ref.read(qaulWorkerProvider); - worker.sendFile( - pathName: file.path, - conversationId: room.conversationId, - description: description.text, - ); - }, - ); - }, - ); - } + // ignore: use_build_context_synchronously + if (!context.mounted) return; + showModalBottomSheet( + context: context, + enableDrag: false, + isDismissible: false, + builder: (_) { + return _RecordAudioDialog( + room: room, + partialMessage: text?.text, + onSendPressed: (file, description) { + final worker = ref.read(qaulWorkerProvider); + worker.sendFile( + pathName: file.path, + conversationId: room.conversationId, + description: description.text, + ); + }, + ); + }, + ); }, ), onMessageTap: (context, message) async { @@ -540,49 +522,6 @@ class _ChatScreenState extends ConsumerState { _overflowMenuOptions.clear(); } } - - Future startRecording() async { - try { - if (await audioRecorder.hasPermission()) { - setState(() { - isRecording = true; - }); - final path = await getNewAudioFilePath(); - await audioRecorder.start(const RecordConfig(), path: path); - } - } catch (e) { - return; - } - } - - Future getNewAudioFilePath() async { - final dir = (Platform.isAndroid) - ? (await getExternalStorageDirectory()) - : (await getApplicationSupportDirectory()); - - if (dir == null) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar(SnackBar( - content: Text(AppLocalizations.of(context)!.genericErrorMessage), - )); - } - return ""; - } - - return join(dir.path, 'audio_${DateTime.now().millisecondsSinceEpoch}.m4a'); - } - - Future stopRecording() async { - try { - final path = await audioRecorder.stop(); - setState(() { - isRecording = false; - audioPath = path!; - }); - } catch (e) { - return; - } - } } extension _MessageExtension on Message { diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart index 9aa667862..471bb94d9 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/custom_input.dart @@ -21,11 +21,10 @@ class _CustomInput extends StatefulWidget { required this.hintText, this.onAttachmentPressed, this.onPickImagePressed, - this.onRecordAudioPressed, + this.onSendAudioPressed, this.initialText, this.disabledMessage, this.isDisabled = false, - this.isRecording = false, }) : super(key: key); final void Function(types.PartialText) onSendPressed; @@ -34,7 +33,7 @@ class _CustomInput extends StatefulWidget { final Function({types.PartialText? text})? onPickImagePressed; - final Function({types.PartialText? text})? onRecordAudioPressed; + final Function({types.PartialText? text})? onSendAudioPressed; final SendButtonVisibilityMode sendButtonVisibilityMode; @@ -46,8 +45,6 @@ class _CustomInput extends StatefulWidget { final String hintText; - final bool isRecording; - @override _CustomInputState createState() => _CustomInputState(); } @@ -185,13 +182,10 @@ class _CustomInputState extends State<_CustomInput> { onPressed: () => _sendFilePressed( widget.onPickImagePressed), ), - if (widget.onRecordAudioPressed != null) + if (widget.onSendAudioPressed != null) _AttachmentButton( - icon: (widget.isRecording) - ? Icons.stop_circle - : Icons.mic_none, - onPressed: () => _sendFilePressed( - widget.onRecordAudioPressed), + icon: Icons.mic_none, + onPressed: widget.onSendAudioPressed, ), ], ), diff --git a/qaul_ui/lib/utils.dart b/qaul_ui/lib/utils.dart index 843f0ccaf..f7c14e485 100644 --- a/qaul_ui/lib/utils.dart +++ b/qaul_ui/lib/utils.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fluent_ui/fluent_ui.dart'; class Responsiveness { @@ -25,3 +27,123 @@ class Responsiveness { static bool isDesktop(BuildContext context) => MediaQuery.of(context).size.width >= kDesktopBreakpoint; } + +class ComplexTimer implements Timer { + final Zone _zone; + final Stopwatch _activeTimer = Stopwatch(); + Timer? _timer; + final Duration _originalDuration; + void Function(ComplexTimer)? _onTimeout; + int _tick = 0; + + ComplexTimer(Duration duration) + : _originalDuration = duration, + _zone = Zone.current { + _startTimer(); + } + + set onTimeout(void Function(ComplexTimer)? callback) { + _onTimeout = callback == null ? null : _zone.bindUnaryCallback(callback); + } + + @override + int get tick => _tick; + + // Whether the timer is actively counting. + @override + bool get isActive => _timer?.isActive ?? false; + + // Whether the timer is started, but not currently actively counting. + bool get isPaused { + var timer = _timer; + return timer != null && !timer.isActive; + } + + // Whether the timer has expired. + bool get isExpired => _timer == null; + + /// Pauses an active timer. + /// + /// Nothing happens if the timer is already paused or expired. + void pause() { + _pauseTimer(); + } + + /// Resumes counting when paused. + /// + /// Nothing happens if the timer is active or expired. + void resume() { + var timer = _timer; + if (timer == null || timer.isActive) return; + _startTimer(); + } + + /// Resets the timer. + /// + /// Sets the timer to its original duration. + /// Does not change whether the timer is active, paused or expired. + void reset() { + var timer = _timer; + if (timer == null) return; // is expired. + _activeTimer + ..stop() + ..reset(); + if (timer.isActive) { + timer.cancel(); + _startTimer(); + } + } + + /// Restarts the timer. + /// + /// Starts counting for the original duration. + /// Works whether the timer is active, paused or expired. + void restart() { + _timer?.cancel(); + _activeTimer + ..stop() + ..reset(); + _startTimer(); + } + + /// Stops and expires the current timer. + /// + /// Nothing happens if the timer is already expired. + @override + void cancel() { + _timer?.cancel(); + _expireTimer(); + } + + void _startTimer() { + var elapsed = _activeTimer.elapsedMilliseconds; + var duration = _originalDuration; + if (elapsed > 0) { + duration = + Duration(milliseconds: _originalDuration.inMilliseconds - elapsed); + } + _timer = _zone.createTimer(duration, _onTick); + _activeTimer.start(); + } + + void _expireTimer() { + _timer = null; + _activeTimer + ..stop() + ..reset(); + } + + void _pauseTimer() { + _timer?.cancel(); + _activeTimer.stop(); + } + + void _onTick() { + _tick++; + _expireTimer(); + var callback = _onTimeout; + if (callback != null) { + _zone.runUnary(callback, this); + } + } +} From 64288c3034a53c639edda924e5b191f12626f8bc Mon Sep 17 00:00:00 2001 From: sukhman-sukh Date: Tue, 9 Apr 2024 04:55:56 +0530 Subject: [PATCH 08/11] Fix : Fixed attachment, images, and audio message url for Windows. Signed-off-by: sukhman-sukh --- qaul_ui/packages/qaul_rpc/lib/src/utils.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/qaul_ui/packages/qaul_rpc/lib/src/utils.dart b/qaul_ui/packages/qaul_rpc/lib/src/utils.dart index 77ac20162..0a4813e40 100644 --- a/qaul_ui/packages/qaul_rpc/lib/src/utils.dart +++ b/qaul_ui/packages/qaul_rpc/lib/src/utils.dart @@ -26,6 +26,9 @@ abstract class FilePathResolverMixin { required String extension, }) { var storagePath = read(libqaulLogsStoragePath)!.replaceAll('/logs', ''); + if (Platform.isWindows){ + storagePath = read(libqaulLogsStoragePath)!.replaceAll('\\logs', ''); + } var uuid = read(defaultUserProvider)!.idBase58; return '$storagePath/$uuid/files/$id.$extension'; From f6b00b1459e96a093c08fd40eea23493f3be022e Mon Sep 17 00:00:00 2001 From: sukhman-sukh Date: Wed, 10 Apr 2024 04:49:14 +0530 Subject: [PATCH 09/11] Feat: Enable audio-chat feature for windows . Signed-off-by: sukhman-sukh --- qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart index 439d2b727..c1a662f27 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart @@ -322,7 +322,8 @@ class _ChatScreenState extends ConsumerState { }, onSendAudioPressed: !(Platform.isAndroid || Platform.isIOS || - Platform.isMacOS) + Platform.isMacOS || + Platform.isWindows) ? null : (room.messages?.isEmpty ?? true) ? null From 4e2597e442c9bd930e933753c0cc26b0f9cc37b3 Mon Sep 17 00:00:00 2001 From: sukhman-sukh Date: Wed, 10 Apr 2024 06:25:25 +0530 Subject: [PATCH 10/11] Feat: Enable audio-chat feature for Linux. Signed-off-by: sukhman-sukh --- .../lib/screens/home/tabs/chat/widgets/audio_recording.dart | 4 ++-- qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_recording.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_recording.dart index 806190b6a..13d84a27f 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_recording.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/audio_recording.dart @@ -143,11 +143,11 @@ class _RecordAudioDialogState extends ConsumerState<_RecordAudioDialog> { children: [ OutlinedButton( onPressed: onCancelPressed, - child: Text('Cancel'), + child: const Text('Cancel'), ), FilledButton( onPressed: onSendPressed, - child: Text('Send'), + child: const Text('Send'), ), ], ), diff --git a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart index c1a662f27..5131f8d0b 100644 --- a/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart +++ b/qaul_ui/lib/screens/home/tabs/chat/widgets/chat.dart @@ -323,7 +323,8 @@ class _ChatScreenState extends ConsumerState { onSendAudioPressed: !(Platform.isAndroid || Platform.isIOS || Platform.isMacOS || - Platform.isWindows) + Platform.isWindows || + Platform.isLinux) ? null : (room.messages?.isEmpty ?? true) ? null From 7cd87d99b0bcd236f62883c10337fe39c0554f42 Mon Sep 17 00:00:00 2001 From: Mathias Jud Date: Fri, 12 Apr 2024 06:21:34 +0200 Subject: [PATCH 11/11] corrected & updated windows build instructions - `Visual Studio 2022` is now required due to the required CMAKE version. - the flutter build path to copy `libqaul.dll` was corrected --- docs/qaul/flutter/windows.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/qaul/flutter/windows.md b/docs/qaul/flutter/windows.md index 1366edc82..e9f3f901c 100644 --- a/docs/qaul/flutter/windows.md +++ b/docs/qaul/flutter/windows.md @@ -3,11 +3,11 @@ ## Prerequisits ### Install Visual Studio -In order to develop for the windows desktop platform you need to have 'Microsoft Visual Studio 2019' or 'Microsoft Visual Studio 2022' installed. +In order to develop for the windows desktop platform you need to have 'Microsoft Visual Studio 2022' installed. -In Microsoft Visual Studio 2019/2022, you need to have 'Desktop development with C++' workload installed. +In Microsoft Visual Studio 2022, you need to have 'Desktop development with C++' workload installed. ### Install Git for Windows @@ -73,7 +73,7 @@ cargo build # copy libqaul.dll to flutter runner ## this step is required in order to run flutter app ## create the location folder if it does not yet exist -cp .\target\debug\libqaul.dll ..\qaul_ui\build\windows\runner\Debug\ +cp .\target\debug\libqaul.dll ..\qaul_ui\build\windows\x64\runner\Debug\ ``` ### Build and Run Windows Desktop App