diff --git a/CHANGELOG.md b/CHANGELOG.md index 22c3ee8..bd1cf72 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 42165b2..439dced 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 0000000..5351e50 --- /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 357ec53..90cf7e0 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 c2e2640..09b8983 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 134d3cc..4894b9c 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 988d5f4..8bacb5d 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 2259f3c..6fb531c 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