Add initial support for sending voice messages
This commit is contained in:
parent
0e15b3c387
commit
ce98e73f6f
8 changed files with 201 additions and 49 deletions
|
@ -3,6 +3,9 @@
|
||||||
|
|
||||||
<!-- Required to fetch data from the internet. -->
|
<!-- Required to fetch data from the internet. -->
|
||||||
<uses-permission android:name="android.permission.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
|
<application
|
||||||
android:label="Contacts++"
|
android:label="Contacts++"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
|
|
|
@ -154,4 +154,33 @@ class RecordApi {
|
||||||
progressCallback?.call(1);
|
progressCallback?.call(1);
|
||||||
return record;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -142,7 +142,7 @@ class MessagingClient extends ChangeNotifier {
|
||||||
};
|
};
|
||||||
_sendData(data);
|
_sendData(data);
|
||||||
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
|
final cache = getUserMessageCache(message.recipientId) ?? _createUserMessageCache(message.recipientId);
|
||||||
cache.messages.add(message);
|
cache.addMessage(message);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -125,7 +125,7 @@ class MessageCache {
|
||||||
bool addMessage(Message message) {
|
bool addMessage(Message message) {
|
||||||
final existingIdx = _messages.indexWhere((element) => element.id == message.id);
|
final existingIdx = _messages.indexWhere((element) => element.id == message.id);
|
||||||
if (existingIdx == -1) {
|
if (existingIdx == -1) {
|
||||||
_messages.add(message);
|
_messages.insert(0, message);
|
||||||
_ensureIntegrity();
|
_ensureIntegrity();
|
||||||
} else {
|
} else {
|
||||||
_messages[existingIdx] = message;
|
_messages[existingIdx] = message;
|
||||||
|
|
|
@ -262,7 +262,7 @@ class Record {
|
||||||
"description": description.asNullable,
|
"description": description.asNullable,
|
||||||
"tags": tags,
|
"tags": tags,
|
||||||
"recordType": recordType.name,
|
"recordType": recordType.name,
|
||||||
"thumbnailUri": thumbnailUri,
|
"thumbnailUri": thumbnailUri.asNullable,
|
||||||
"isPublic": isPublic,
|
"isPublic": isPublic,
|
||||||
"isForPatreons": isForPatreons,
|
"isForPatreons": isForPatreons,
|
||||||
"isListed": isListed,
|
"isListed": isListed,
|
||||||
|
@ -288,4 +288,14 @@ class Record {
|
||||||
static String generateId() {
|
static String generateId() {
|
||||||
return "R-${const Uuid().v4()}";
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -4,7 +4,6 @@ import 'dart:io' show Platform;
|
||||||
import 'package:contacts_plus_plus/auxiliary.dart';
|
import 'package:contacts_plus_plus/auxiliary.dart';
|
||||||
import 'package:contacts_plus_plus/models/message.dart';
|
import 'package:contacts_plus_plus/models/message.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/messages/message_state_indicator.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:flutter/material.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
|
|
61
lib/widgets/messages/message_record_button.dart
Normal file
61
lib/widgets/messages/message_record_button.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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/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_attachment_list.dart';
|
||||||
import 'package:contacts_plus_plus/widgets/messages/message_camera_view.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:contacts_plus_plus/widgets/messages/messages_session_header.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
@ -101,7 +102,8 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
_hasText = false;
|
_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(
|
final record = await RecordApi.uploadImage(
|
||||||
client,
|
client,
|
||||||
image: file,
|
image: file,
|
||||||
|
@ -109,7 +111,7 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
progressCallback: progressCallback,
|
progressCallback: progressCallback,
|
||||||
);
|
);
|
||||||
final message = Message(
|
final message = Message(
|
||||||
id: Message.generateId(),
|
id: record.extractMessageId() ?? Message.generateId(),
|
||||||
recipientId: widget.friend.id,
|
recipientId: widget.friend.id,
|
||||||
senderId: client.userId,
|
senderId: client.userId,
|
||||||
type: MessageType.object,
|
type: MessageType.object,
|
||||||
|
@ -121,6 +123,29 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
_hasText = false;
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final apiClient = ClientHolder
|
final apiClient = ClientHolder
|
||||||
|
@ -285,7 +310,8 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
switchInCurve: Curves.easeOut,
|
switchInCurve: Curves.easeOut,
|
||||||
switchOutCurve: 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)) {
|
child: switch ((_attachmentPickerOpen, _loadedFiles)) {
|
||||||
(true, []) =>
|
(true, []) =>
|
||||||
Row(
|
Row(
|
||||||
|
@ -319,14 +345,16 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
(false, []) => null,
|
(false, []) => null,
|
||||||
(_, _) => MessageAttachmentList(
|
(_, _) =>
|
||||||
disabled: _isSending,
|
MessageAttachmentList(
|
||||||
initialFiles: _loadedFiles,
|
disabled: _isSending,
|
||||||
onChange: (List<File> loadedFiles) => setState(() {
|
initialFiles: _loadedFiles,
|
||||||
_loadedFiles.clear();
|
onChange: (List<File> loadedFiles) =>
|
||||||
_loadedFiles.addAll(loadedFiles);
|
setState(() {
|
||||||
}),
|
_loadedFiles.clear();
|
||||||
)
|
_loadedFiles.addAll(loadedFiles);
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -335,9 +363,9 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
),
|
),
|
||||||
if (_isSending && _sendProgress != null)
|
if (_isSending && _sendProgress != null)
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: LinearProgressIndicator(value: _sendProgress),
|
child: LinearProgressIndicator(value: _sendProgress),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -374,7 +402,7 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
child: !_attachmentPickerOpen ?
|
child: !_attachmentPickerOpen ?
|
||||||
IconButton(
|
IconButton(
|
||||||
key: const ValueKey("add-attachment-icon"),
|
key: const ValueKey("add-attachment-icon"),
|
||||||
onPressed:_isSending ? null : () {
|
onPressed: _isSending ? null : () {
|
||||||
setState(() {
|
setState(() {
|
||||||
_attachmentPickerOpen = true;
|
_attachmentPickerOpen = true;
|
||||||
});
|
});
|
||||||
|
@ -385,28 +413,29 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
key: const ValueKey("remove-attachment-icon"),
|
key: const ValueKey("remove-attachment-icon"),
|
||||||
onPressed: _isSending ? null : () async {
|
onPressed: _isSending ? null : () async {
|
||||||
if (_loadedFiles.isNotEmpty) {
|
if (_loadedFiles.isNotEmpty) {
|
||||||
await showDialog(context: context, builder: (context) => AlertDialog(
|
await showDialog(context: context, builder: (context) =>
|
||||||
title: const Text("Remove all attachments"),
|
AlertDialog(
|
||||||
content: const Text("This will remove all attachments, are you sure?"),
|
title: const Text("Remove all attachments"),
|
||||||
actions: [
|
content: const Text("This will remove all attachments, are you sure?"),
|
||||||
TextButton(
|
actions: [
|
||||||
onPressed: () {
|
TextButton(
|
||||||
Navigator.of(context).pop();
|
onPressed: () {
|
||||||
},
|
Navigator.of(context).pop();
|
||||||
child: const Text("No"),
|
},
|
||||||
),
|
child: const Text("No"),
|
||||||
TextButton(
|
),
|
||||||
onPressed: () {
|
TextButton(
|
||||||
setState(() {
|
onPressed: () {
|
||||||
_loadedFiles.clear();
|
setState(() {
|
||||||
_attachmentPickerOpen = false;
|
_loadedFiles.clear();
|
||||||
});
|
_attachmentPickerOpen = false;
|
||||||
Navigator.of(context).pop();
|
});
|
||||||
},
|
Navigator.of(context).pop();
|
||||||
child: const Text("Yes"),
|
},
|
||||||
)
|
child: const Text("Yes"),
|
||||||
],
|
)
|
||||||
));
|
],
|
||||||
|
));
|
||||||
} else {
|
} else {
|
||||||
setState(() {
|
setState(() {
|
||||||
_attachmentPickerOpen = false;
|
_attachmentPickerOpen = false;
|
||||||
|
@ -470,7 +499,7 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
for (int i = 0; i < toSend.length; i++) {
|
for (int i = 0; i < toSend.length; i++) {
|
||||||
final totalProgress = i/toSend.length;
|
final totalProgress = i / toSend.length;
|
||||||
final file = toSend[i];
|
final file = toSend[i];
|
||||||
await sendImageMessage(apiClient, mClient, file, ClientHolder
|
await sendImageMessage(apiClient, mClient, file, ClientHolder
|
||||||
.of(context)
|
.of(context)
|
||||||
|
@ -480,7 +509,7 @@ class _MessagesListState extends State<MessagesList> with SingleTickerProviderSt
|
||||||
.valueOrDefault,
|
.valueOrDefault,
|
||||||
(progress) =>
|
(progress) =>
|
||||||
setState(() {
|
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,
|
iconSize: 28,
|
||||||
icon: const Icon(Icons.send),
|
icon: const Icon(Icons.send),
|
||||||
) : IconButton(
|
) : MessageRecordButton(
|
||||||
key: const ValueKey("mic-button"),
|
key: const ValueKey("mic-button"),
|
||||||
splashRadius: 24,
|
disabled: _isSending,
|
||||||
onPressed: _isSending ? null : () async {
|
onRecordEnd: (File? file) async {
|
||||||
// TODO: Implement voice message recording
|
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),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
Loading…
Reference in a new issue