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),
),
),
),