diff --git a/CHANGELOG.md b/CHANGELOG.md
index 22c3ee86..bd1cf72f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,17 @@
+# Version 0.10.0 - 2020-03-??
+### New features
+- Voice messages
+- New message bubble design
+
+# Version 0.9.0 - 2020-03-13
+### New features
+- Improved design
+- End2End encryption for normal messages (not yet files)
+- Key sharing
+- Device keys verification UI
+### Fixes
+- Minor bug fixes
+
# Version 0.8.2 - 2020-02-17
### Fixes
- SpeedDial labels not visible in light mode
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 42165b20..439dcede 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -7,6 +7,7 @@
FlutterApplication and put your custom class here. -->
+
_AudioPlayerState();
+}
+
+enum AudioPlayerStatus { NOT_DOWNLOADED, DOWNLOADING, DOWNLOADED }
+
+class _AudioPlayerState extends State {
+ AudioPlayerStatus status = AudioPlayerStatus.NOT_DOWNLOADED;
+
+ FlutterSound flutterSound = FlutterSound();
+
+ StreamSubscription soundSubscription;
+
+ static var httpClient = HttpClient();
+ Uint8List audioFile;
+
+ String statusText = "00:00";
+ double currentPosition = 0;
+ double maxPosition = 0;
+
+ @override
+ void dispose() {
+ if (flutterSound.audioState == t_AUDIO_STATE.IS_PLAYING) {
+ flutterSound.stopPlayer();
+ }
+ soundSubscription?.cancel();
+ super.dispose();
+ }
+
+ _downloadAction() async {
+ if (status != AudioPlayerStatus.NOT_DOWNLOADED) return;
+ setState(() => status = AudioPlayerStatus.DOWNLOADING);
+ String url = widget.content.getDownloadLink(Matrix.of(context).client);
+ var request = await httpClient.getUrl(Uri.parse(url));
+ var response = await request.close();
+ var bytes = await consolidateHttpClientResponseBytes(response);
+ setState(() {
+ audioFile = bytes;
+ status = AudioPlayerStatus.DOWNLOADED;
+ });
+ _playAction();
+ }
+
+ _playAction() async {
+ switch (flutterSound.audioState) {
+ case t_AUDIO_STATE.IS_PLAYING:
+ await flutterSound.pausePlayer();
+ break;
+ case t_AUDIO_STATE.IS_PAUSED:
+ await flutterSound.resumePlayer();
+ break;
+ case t_AUDIO_STATE.IS_RECORDING:
+ break;
+ case t_AUDIO_STATE.IS_STOPPED:
+ await flutterSound.startPlayerFromBuffer(
+ audioFile,
+ codec: t_CODEC.CODEC_AAC,
+ );
+ soundSubscription ??= flutterSound.onPlayerStateChanged.listen((e) {
+ if (e != null) {
+ DateTime date =
+ DateTime.fromMillisecondsSinceEpoch(e.currentPosition.toInt());
+ String txt = DateFormat('mm:ss', 'en_US').format(date);
+ this.setState(() {
+ maxPosition = e.duration;
+ currentPosition = e.currentPosition;
+ statusText = txt;
+ });
+ if (e.duration == e.currentPosition) {
+ soundSubscription
+ ?.cancel()
+ ?.then((f) => soundSubscription = null);
+ }
+ }
+ });
+ break;
+ }
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Container(
+ width: 40,
+ child: status == AudioPlayerStatus.DOWNLOADING
+ ? CircularProgressIndicator()
+ : IconButton(
+ icon: Icon(
+ flutterSound.audioState == t_AUDIO_STATE.IS_PLAYING
+ ? Icons.pause
+ : Icons.play_arrow,
+ color: widget.color,
+ ),
+ onPressed: () {
+ if (status == AudioPlayerStatus.DOWNLOADED) {
+ _playAction();
+ } else {
+ _downloadAction();
+ }
+ },
+ ),
+ ),
+ Slider(
+ value: currentPosition,
+ onChanged: (double position) =>
+ flutterSound.seekToPlayer(position.toInt()),
+ max: status == AudioPlayerStatus.DOWNLOADED ? maxPosition : 0,
+ min: 0,
+ ),
+ Text(
+ statusText,
+ style: TextStyle(
+ color: widget.color,
+ ),
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/components/dialogs/recording_dialog.dart b/lib/components/dialogs/recording_dialog.dart
new file mode 100644
index 00000000..5351e50a
--- /dev/null
+++ b/lib/components/dialogs/recording_dialog.dart
@@ -0,0 +1,97 @@
+import 'dart:async';
+
+import 'package:fluffychat/i18n/i18n.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_sound/flutter_sound.dart';
+import 'package:intl/intl.dart';
+
+class RecordingDialog extends StatefulWidget {
+ final Function onFinished;
+
+ const RecordingDialog({this.onFinished, Key key}) : super(key: key);
+
+ @override
+ _RecordingDialogState createState() => _RecordingDialogState();
+}
+
+class _RecordingDialogState extends State {
+ FlutterSound flutterSound = FlutterSound();
+ String time = "00:00:00";
+
+ StreamSubscription _recorderSubscription;
+
+ void startRecording() async {
+ await flutterSound.startRecorder(
+ codec: t_CODEC.CODEC_AAC,
+ );
+ _recorderSubscription = flutterSound.onRecorderStateChanged.listen((e) {
+ DateTime date =
+ DateTime.fromMillisecondsSinceEpoch(e.currentPosition.toInt());
+ setState(() => time = DateFormat('mm:ss:SS', 'en_US').format(date));
+ });
+ }
+
+ @override
+ void initState() {
+ super.initState();
+ startRecording();
+ }
+
+ @override
+ void dispose() {
+ if (flutterSound.isRecording) flutterSound.stopRecorder();
+ _recorderSubscription?.cancel();
+ super.dispose();
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return AlertDialog(
+ content: Row(
+ children: [
+ CircleAvatar(
+ backgroundColor: Colors.red,
+ radius: 8,
+ ),
+ SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ "${I18n.of(context).recording}: $time",
+ style: TextStyle(
+ fontSize: 18,
+ ),
+ ),
+ ),
+ ],
+ ),
+ actions: [
+ FlatButton(
+ child: Text(
+ I18n.of(context).cancel.toUpperCase(),
+ style: TextStyle(
+ color: Theme.of(context).textTheme.body1.color.withAlpha(150),
+ ),
+ ),
+ onPressed: () => Navigator.of(context).pop(),
+ ),
+ FlatButton(
+ child: Row(
+ children: [
+ Text(I18n.of(context).send.toUpperCase()),
+ SizedBox(width: 4),
+ Icon(Icons.send, size: 15),
+ ],
+ ),
+ onPressed: () async {
+ await _recorderSubscription?.cancel();
+ final String result = await flutterSound.stopRecorder();
+ if (widget.onFinished != null) {
+ widget.onFinished(result);
+ }
+ Navigator.of(context).pop();
+ },
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/components/message_content.dart b/lib/components/message_content.dart
index 357ec531..90cf7e00 100644
--- a/lib/components/message_content.dart
+++ b/lib/components/message_content.dart
@@ -1,6 +1,7 @@
import 'package:bubble/bubble.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:famedlysdk/famedlysdk.dart';
+import 'package:fluffychat/components/audio_player.dart';
import 'package:fluffychat/i18n/i18n.dart';
import 'package:fluffychat/utils/event_extension.dart';
import 'package:fluffychat/views/image_viewer.dart';
@@ -59,6 +60,10 @@ class MessageContent extends StatelessWidget {
),
);
case MessageTypes.Audio:
+ return AudioPlayer(
+ MxContent(event.content["url"]),
+ color: textColor,
+ );
case MessageTypes.Video:
case MessageTypes.File:
return Container(
diff --git a/lib/i18n/i18n.dart b/lib/i18n/i18n.dart
index c2e26405..09b8983a 100644
--- a/lib/i18n/i18n.dart
+++ b/lib/i18n/i18n.dart
@@ -94,6 +94,8 @@ class I18n {
args: [username, targetName],
);
+ String get cancel => Intl.message("Cancel");
+
String changedTheChatAvatar(String username) => Intl.message(
"$username changed the chat avatar",
name: "changedTheChatAvatar",
@@ -484,6 +486,8 @@ class I18n {
String get rejoin => Intl.message("Rejoin");
+ String get recording => Intl.message("Recording");
+
String redactedAnEvent(String username) => Intl.message(
"$username redacted an event",
name: "redactedAnEvent",
@@ -555,6 +559,8 @@ class I18n {
args: [username, count],
);
+ String get send => Intl.message("Send");
+
String get sendAMessage => Intl.message("Send a message");
String get sendFile => Intl.message('Send file');
@@ -714,6 +720,8 @@ class I18n {
String get visibilityOfTheChatHistory =>
Intl.message("Visibility of the chat history");
+ String get voiceMessage => Intl.message("Voice message");
+
String get warningEncryptionInBeta => Intl.message(
"End to end encryption is currently in Beta! Use at your own risk!");
diff --git a/lib/views/chat.dart b/lib/views/chat.dart
index 134d3cc7..4894b9ca 100644
--- a/lib/views/chat.dart
+++ b/lib/views/chat.dart
@@ -5,6 +5,7 @@ import 'package:famedlysdk/famedlysdk.dart';
import 'package:file_picker/file_picker.dart';
import 'package:fluffychat/components/adaptive_page_layout.dart';
import 'package:fluffychat/components/chat_settings_popup_menu.dart';
+import 'package:fluffychat/components/dialogs/recording_dialog.dart';
import 'package:fluffychat/components/dialogs/simple_dialogs.dart';
import 'package:fluffychat/components/encryption_button.dart';
import 'package:fluffychat/components/list_items/message.dart';
@@ -222,6 +223,22 @@ class _ChatState extends State<_Chat> {
);
}
+ void voiceMessageAction(BuildContext context) async {
+ String result;
+ await showDialog(
+ context: context,
+ builder: (context) => RecordingDialog(
+ onFinished: (r) => result = r,
+ ));
+ if (result == null) return;
+ final File audioFile = File(result);
+ await Matrix.of(context).tryRequestWithLoadingDialog(
+ room.sendAudioEvent(
+ MatrixFile(bytes: audioFile.readAsBytesSync(), path: audioFile.path),
+ ),
+ );
+ }
+
String _getSelectedEventString(BuildContext context) {
String copyString = "";
for (Event event in selectedEvents) {
@@ -565,6 +582,9 @@ class _ChatState extends State<_Chat> {
if (choice == "camera") {
openCameraAction(context);
}
+ if (choice == "voice") {
+ voiceMessageAction(context);
+ }
},
itemBuilder: (BuildContext context) =>
>[
@@ -607,6 +627,19 @@ class _ChatState extends State<_Chat> {
contentPadding: EdgeInsets.all(0),
),
),
+ PopupMenuItem(
+ value: "voice",
+ child: ListTile(
+ leading: CircleAvatar(
+ backgroundColor: Colors.red,
+ foregroundColor: Colors.white,
+ child: Icon(Icons.mic),
+ ),
+ title: Text(
+ I18n.of(context).voiceMessage),
+ contentPadding: EdgeInsets.all(0),
+ ),
+ ),
],
),
EncryptionButton(room),
@@ -656,10 +689,17 @@ class _ChatState extends State<_Chat> {
),
),
),
- IconButton(
- icon: Icon(Icons.send),
- onPressed: () => send(),
- ),
+ if (!kIsWeb && inputText.isEmpty)
+ IconButton(
+ icon: Icon(Icons.mic),
+ onPressed: () =>
+ voiceMessageAction(context),
+ ),
+ if (kIsWeb || inputText.isNotEmpty)
+ IconButton(
+ icon: Icon(Icons.send),
+ onPressed: () => send(),
+ ),
],
),
)
diff --git a/pubspec.lock b/pubspec.lock
index 988d5f40..8bacb5d1 100644
--- a/pubspec.lock
+++ b/pubspec.lock
@@ -195,6 +195,13 @@ packages:
url: "https://pub.dartlang.org"
source: hosted
version: "0.5.4"
+ flutter_sound:
+ dependency: "direct main"
+ description:
+ name: flutter_sound
+ url: "https://pub.dartlang.org"
+ source: hosted
+ version: "2.1.1"
flutter_speed_dial:
dependency: "direct main"
description:
diff --git a/pubspec.yaml b/pubspec.yaml
index 2259f3cf..6fb531cb 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -52,6 +52,7 @@ dependencies:
flutter_svg: ^0.17.1
flutter_slidable: ^0.5.4
photo_view: ^0.9.2
+ flutter_sound: ^2.1.1
intl: ^0.16.0
intl_translation: ^0.17.9