Add initial support for sending voice messages

This commit is contained in:
Nutcake 2023-05-18 13:52:34 +02:00
parent 0e15b3c387
commit ce98e73f6f
8 changed files with 201 additions and 49 deletions

View file

@ -3,6 +3,9 @@
<!-- Required to fetch data from the internet. -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<!-- Optional, you'll have to check this permission by yourself. -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<application
android:label="Contacts++"
android:name="${applicationName}"

View file

@ -154,4 +154,33 @@ class RecordApi {
progressCallback?.call(1);
return record;
}
static Future<Record> 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;
}
}

View file

@ -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();
}

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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';

View file

@ -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<MessageRecordButton> createState() => _MessageRecordButtonState();
}
class _MessageRecordButtonState extends State<MessageRecordButton> {
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),
),
),
);
}
}

View file

@ -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<MessagesList> with SingleTickerProviderSt
_hasText = false;
}
Future<void> sendImageMessage(ApiClient client, MessagingClient mClient, File file, String machineId, void Function(double progress) progressCallback) async {
Future<void> 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<MessagesList> 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<MessagesList> with SingleTickerProviderSt
_hasText = false;
}
Future<void> 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<MessagesList> 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<MessagesList> with SingleTickerProviderSt
],
),
(false, []) => null,
(_, _) => MessageAttachmentList(
disabled: _isSending,
initialFiles: _loadedFiles,
onChange: (List<File> loadedFiles) => setState(() {
_loadedFiles.clear();
_loadedFiles.addAll(loadedFiles);
}),
)
(_, _) =>
MessageAttachmentList(
disabled: _isSending,
initialFiles: _loadedFiles,
onChange: (List<File> loadedFiles) =>
setState(() {
_loadedFiles.clear();
_loadedFiles.addAll(loadedFiles);
}),
)
},
),
),
@ -335,9 +363,9 @@ class _MessagesListState extends State<MessagesList> 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<MessagesList> 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<MessagesList> 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<MessagesList> 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<MessagesList> 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<MessagesList> 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),
),
),
),