From ce98e73f6fdd2e68838e9372bf4eb607846d1331 Mon Sep 17 00:00:00 2001 From: Nutcake Date: Thu, 18 May 2023 13:52:34 +0200 Subject: [PATCH] Add initial support for sending voice messages --- android/app/src/main/AndroidManifest.xml | 3 + lib/apis/record_api.dart | 29 ++++ lib/clients/messaging_client.dart | 2 +- lib/models/message.dart | 2 +- lib/models/records/record.dart | 12 +- .../messages/message_audio_player.dart | 1 - .../messages/message_record_button.dart | 61 ++++++++ lib/widgets/messages/messages_list.dart | 140 ++++++++++++------ 8 files changed, 201 insertions(+), 49 deletions(-) create mode 100644 lib/widgets/messages/message_record_button.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d0ce8d4..f24ce14 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,9 @@ + + + uploadVoiceClip(ApiClient client, {required File voiceClip, required String machineId, void Function(double progress)? progressCallback}) async { + progressCallback?.call(0); + final voiceDigest = await AssetDigest.fromData(await voiceClip.readAsBytes(), basename(voiceClip.path)); + + final filename = basenameWithoutExtension(voiceClip.path); + final digests = [voiceDigest]; + + final record = Record.fromRequiredData( + recordType: RecordType.texture, + userId: client.userId, + machineId: machineId, + assetUri: voiceDigest.dbUri, + filename: filename, + thumbnailUri: "", + digests: digests, + ); + progressCallback?.call(.1); + final status = await tryPreprocessRecord(client, record: record); + final toUpload = status.resultDiffs.whereNot((element) => element.isUploaded); + progressCallback?.call(.2); + + await uploadAssets( + client, + assets: digests.where((digest) => toUpload.any((diff) => digest.asset.hash == diff.hash)).toList(), + progressCallback: (progress) => progressCallback?.call(.2 + progress * .6)); + progressCallback?.call(1); + return record; + } } diff --git a/lib/clients/messaging_client.dart b/lib/clients/messaging_client.dart index 96868a6..dffb3e8 100644 --- a/lib/clients/messaging_client.dart +++ b/lib/clients/messaging_client.dart @@ -142,7 +142,7 @@ class MessagingClient extends ChangeNotifier { }; _sendData(data); final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId); - cache.messages.add(message); + cache.addMessage(message); notifyListeners(); } diff --git a/lib/models/message.dart b/lib/models/message.dart index 4afd900..1cb6c05 100644 --- a/lib/models/message.dart +++ b/lib/models/message.dart @@ -125,7 +125,7 @@ class MessageCache { bool addMessage(Message message) { final existingIdx = _messages.indexWhere((element) => element.id == message.id); if (existingIdx == -1) { - _messages.add(message); + _messages.insert(0, message); _ensureIntegrity(); } else { _messages[existingIdx] = message; diff --git a/lib/models/records/record.dart b/lib/models/records/record.dart index 40e5bfd..9d3a910 100644 --- a/lib/models/records/record.dart +++ b/lib/models/records/record.dart @@ -262,7 +262,7 @@ class Record { "description": description.asNullable, "tags": tags, "recordType": recordType.name, - "thumbnailUri": thumbnailUri, + "thumbnailUri": thumbnailUri.asNullable, "isPublic": isPublic, "isForPatreons": isForPatreons, "isListed": isListed, @@ -288,4 +288,14 @@ class Record { static String generateId() { return "R-${const Uuid().v4()}"; } + + String? extractMessageId() { + const key = "message_id:"; + for (final tag in tags) { + if (tag.startsWith(key)) { + return tag.replaceFirst(key, ""); + } + } + return null; + } } \ No newline at end of file diff --git a/lib/widgets/messages/message_audio_player.dart b/lib/widgets/messages/message_audio_player.dart index 5a864a9..191c9eb 100644 --- a/lib/widgets/messages/message_audio_player.dart +++ b/lib/widgets/messages/message_audio_player.dart @@ -4,7 +4,6 @@ import 'dart:io' show Platform; import 'package:contacts_plus_plus/auxiliary.dart'; import 'package:contacts_plus_plus/models/message.dart'; import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.dart'; -import 'package:dynamic_color/dynamic_color.dart'; import 'package:flutter/material.dart'; import 'package:just_audio/just_audio.dart'; diff --git a/lib/widgets/messages/message_record_button.dart b/lib/widgets/messages/message_record_button.dart new file mode 100644 index 0000000..f77aaaf --- /dev/null +++ b/lib/widgets/messages/message_record_button.dart @@ -0,0 +1,61 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; +import 'package:uuid/uuid.dart'; + +class MessageRecordButton extends StatefulWidget { + const MessageRecordButton({required this.disabled, this.onRecordStart, this.onRecordEnd, super.key}); + + final bool disabled; + final Function()? onRecordStart; + final Function(File? recording)? onRecordEnd; + + @override + State createState() => _MessageRecordButtonState(); +} + +class _MessageRecordButtonState extends State { + + final Record _recorder = Record(); + + @override + void dispose() { + super.dispose(); + Future.delayed(Duration.zero, _recorder.stop); + Future.delayed(Duration.zero, _recorder.dispose); + } + + @override + Widget build(BuildContext context) { + return Material( + child: GestureDetector( + onTapDown: widget.disabled ? null : (_) async { + // TODO: Implement voice message recording + debugPrint("Down"); + HapticFeedback.vibrate(); + widget.onRecordStart?.call(); + final dir = await getTemporaryDirectory(); + await _recorder.start( + path: "${dir.path}/A-${const Uuid().v4()}.wav", + encoder: AudioEncoder.wav, + samplingRate: 44100, + ); + }, + onTapUp: (_) async { + debugPrint("Up"); + if (await _recorder.isRecording()) { + final recording = await _recorder.stop(); + widget.onRecordEnd?.call(recording == null ? null : File(recording)); + } + }, + child: const Padding( + padding: EdgeInsets.all(8.0), + child: Icon(Icons.mic_outlined), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/messages/messages_list.dart b/lib/widgets/messages/messages_list.dart index 88e1cc8..04466cf 100644 --- a/lib/widgets/messages/messages_list.dart +++ b/lib/widgets/messages/messages_list.dart @@ -11,6 +11,7 @@ import 'package:contacts_plus_plus/widgets/default_error_widget.dart'; import 'package:contacts_plus_plus/widgets/friends/friend_online_status_indicator.dart'; import 'package:contacts_plus_plus/widgets/messages/message_attachment_list.dart'; import 'package:contacts_plus_plus/widgets/messages/message_camera_view.dart'; +import 'package:contacts_plus_plus/widgets/messages/message_record_button.dart'; import 'package:contacts_plus_plus/widgets/messages/messages_session_header.dart'; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -101,7 +102,8 @@ class _MessagesListState extends State with SingleTickerProviderSt _hasText = false; } - Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId, void Function(double progress) progressCallback) async { + Future sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId, + void Function(double progress) progressCallback) async { final record = await RecordApi.uploadImage( client, image: file, @@ -109,7 +111,7 @@ class _MessagesListState extends State with SingleTickerProviderSt progressCallback: progressCallback, ); final message = Message( - id: Message.generateId(), + id: record.extractMessageId() ?? Message.generateId(), recipientId: widget.friend.id, senderId: client.userId, type: MessageType.object, @@ -121,6 +123,29 @@ class _MessagesListState extends State with SingleTickerProviderSt _hasText = false; } + + Future sendVoiceMessage(ApiClient client, MessagingClient mClient, File file, String machineId, + void Function(double progress) progressCallback) async { + final record = await RecordApi.uploadVoiceClip( + client, + voiceClip: file, + machineId: machineId, + progressCallback: progressCallback, + ); + final message = Message( + id: record.extractMessageId() ?? Message.generateId(), + recipientId: widget.friend.id, + senderId: client.userId, + type: MessageType.sound, + content: jsonEncode(record.toMap()), + sendTime: DateTime.now().toUtc(), + ); + mClient.sendMessage(message); + _messageTextController.clear(); + _hasText = false; + } + + @override Widget build(BuildContext context) { final apiClient = ClientHolder @@ -285,7 +310,8 @@ class _MessagesListState extends State with SingleTickerProviderSt duration: const Duration(milliseconds: 200), switchInCurve: Curves.easeOut, switchOutCurve: Curves.easeOut, - transitionBuilder: (Widget child, animation) => SizeTransition(sizeFactor: animation, child: child,), + transitionBuilder: (Widget child, animation) => + SizeTransition(sizeFactor: animation, child: child,), child: switch ((_attachmentPickerOpen, _loadedFiles)) { (true, []) => Row( @@ -319,14 +345,16 @@ class _MessagesListState extends State with SingleTickerProviderSt ], ), (false, []) => null, - (_, _) => MessageAttachmentList( - disabled: _isSending, - initialFiles: _loadedFiles, - onChange: (List loadedFiles) => setState(() { - _loadedFiles.clear(); - _loadedFiles.addAll(loadedFiles); - }), - ) + (_, _) => + MessageAttachmentList( + disabled: _isSending, + initialFiles: _loadedFiles, + onChange: (List loadedFiles) => + setState(() { + _loadedFiles.clear(); + _loadedFiles.addAll(loadedFiles); + }), + ) }, ), ), @@ -335,9 +363,9 @@ class _MessagesListState extends State with SingleTickerProviderSt ), if (_isSending && _sendProgress != null) Align( - alignment: Alignment.bottomCenter, - child: LinearProgressIndicator(value: _sendProgress), - ), + alignment: Alignment.bottomCenter, + child: LinearProgressIndicator(value: _sendProgress), + ), ], ), ), @@ -374,7 +402,7 @@ class _MessagesListState extends State with SingleTickerProviderSt child: !_attachmentPickerOpen ? IconButton( key: const ValueKey("add-attachment-icon"), - onPressed:_isSending ? null : () { + onPressed: _isSending ? null : () { setState(() { _attachmentPickerOpen = true; }); @@ -385,28 +413,29 @@ class _MessagesListState extends State with SingleTickerProviderSt key: const ValueKey("remove-attachment-icon"), onPressed: _isSending ? null : () async { if (_loadedFiles.isNotEmpty) { - await showDialog(context: context, builder: (context) => AlertDialog( - title: const Text("Remove all attachments"), - content: const Text("This will remove all attachments, are you sure?"), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text("No"), - ), - TextButton( - onPressed: () { - setState(() { - _loadedFiles.clear(); - _attachmentPickerOpen = false; - }); - Navigator.of(context).pop(); - }, - child: const Text("Yes"), - ) - ], - )); + await showDialog(context: context, builder: (context) => + AlertDialog( + title: const Text("Remove all attachments"), + content: const Text("This will remove all attachments, are you sure?"), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text("No"), + ), + TextButton( + onPressed: () { + setState(() { + _loadedFiles.clear(); + _attachmentPickerOpen = false; + }); + Navigator.of(context).pop(); + }, + child: const Text("Yes"), + ) + ], + )); } else { setState(() { _attachmentPickerOpen = false; @@ -470,7 +499,7 @@ class _MessagesListState extends State with SingleTickerProviderSt }); try { for (int i = 0; i < toSend.length; i++) { - final totalProgress = i/toSend.length; + final totalProgress = i / toSend.length; final file = toSend[i]; await sendImageMessage(apiClient, mClient, file, ClientHolder .of(context) @@ -480,7 +509,7 @@ class _MessagesListState extends State with SingleTickerProviderSt .valueOrDefault, (progress) => setState(() { - _sendProgress = totalProgress + progress * 1/toSend.length; + _sendProgress = totalProgress + progress * 1 / toSend.length; }), ); } @@ -506,14 +535,35 @@ class _MessagesListState extends State with SingleTickerProviderSt }, iconSize: 28, icon: const Icon(Icons.send), - ) : IconButton( + ) : MessageRecordButton( key: const ValueKey("mic-button"), - splashRadius: 24, - onPressed: _isSending ? null : () async { - // TODO: Implement voice message recording + disabled: _isSending, + onRecordEnd: (File? file) async { + if (file == null) return; + setState(() { + _isSending = true; + _sendProgress = 0; + }); + await sendVoiceMessage( + apiClient, + mClient, + file, + ClientHolder + .of(context) + .settingsClient + .currentSettings + .machineId + .valueOrDefault, (progress) { + setState(() { + _sendProgress = progress; + }); + } + ); + setState(() { + _isSending = false; + _sendProgress = null; + }); }, - iconSize: 28, - icon: const Icon(Icons.mic_outlined), ), ), ),