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. -->
|
||||
<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}"
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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';
|
||||
|
||||
|
|
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/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),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
Loading…
Reference in a new issue