diff --git a/lib/helpers/conversation_files_helper.dart b/lib/helpers/conversation_files_helper.dart new file mode 100644 index 0000000..386a381 --- /dev/null +++ b/lib/helpers/conversation_files_helper.dart @@ -0,0 +1,46 @@ +import 'dart:io'; + +import 'package:comunic/models/conversation_message.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:path/path.dart' as path; +import 'package:path_provider/path_provider.dart'; + +/// Conversation files helper +/// +/// @author Pierre Hubert + +class ConversationFilesHelper { + /// Get the path for chat file + static Future getPathForChatFile( + int msgID, ConversationMessageFile fileInfo) async { + Directory basePath = await getTemporaryDirectory(); + + final storageDir = path.join(basePath.absolute.path, "conversation-files"); + final fileName = "$msgID"; + + return File(path.join(storageDir, fileName)); + } + + /// Download chat file + static Future download({ + @required int msgID, + @required ConversationMessageFile fileInfo, + @required Function(double) onProgress, + @required CancelToken cancelToken, + }) async { + final target = await getPathForChatFile(msgID, fileInfo); + + // Create parent directory if required + if (!await target.parent.exists()) { + await target.parent.create(recursive: true); + } + + await Dio().download( + fileInfo.url, + target.path, + cancelToken: cancelToken, + onReceiveProgress: (p, t) => onProgress(p / fileInfo.size.toDouble()), + ); + } +} diff --git a/lib/models/conversation.dart b/lib/models/conversation.dart index 0bfe197..635acb6 100644 --- a/lib/models/conversation.dart +++ b/lib/models/conversation.dart @@ -68,7 +68,8 @@ class Conversation extends SerializableElement { lastActivity = map["lastActivity"], members = map["members"] .map((el) => ConversationMember.fromJSON(el)) - .toList(), + .toList() + .cast(), canEveryoneAddMembers = map["canEveryoneAddMembers"], // By default, we can not do any call diff --git a/lib/models/conversation_message.dart b/lib/models/conversation_message.dart index 2e2b990..403df94 100644 --- a/lib/models/conversation_message.dart +++ b/lib/models/conversation_message.dart @@ -3,12 +3,33 @@ import 'package:comunic/lists/users_list.dart'; import 'package:comunic/models/displayed_content.dart'; import 'package:comunic/utils/account_utils.dart' as account; import 'package:comunic/utils/intl_utils.dart'; +import 'package:flutter/material.dart'; import 'package:meta/meta.dart'; /// Single conversation message /// /// @author Pierre HUBERT +enum ConversationMessageFileType { + IMAGE, + VIDEO, + AUDIO, + PDF, + ZIP, + OTHER, +} + +const _ConversationFileMimeTypeMapping = { + "image/jpeg": ConversationMessageFileType.IMAGE, + "image/png": ConversationMessageFileType.IMAGE, + "image/gif": ConversationMessageFileType.IMAGE, + "video/mp4": ConversationMessageFileType.VIDEO, + "audio/m4a": ConversationMessageFileType.AUDIO, + "audio/mpeg": ConversationMessageFileType.AUDIO, + "application/pdf": ConversationMessageFileType.PDF, + "application/zip": ConversationMessageFileType.ZIP, +}; + class ConversationMessageFile { final String url; final int size; @@ -27,6 +48,38 @@ class ConversationMessageFile { assert(name != null), assert(type != null); + /// Get the type of file + ConversationMessageFileType get fileType { + if (type != null && _ConversationFileMimeTypeMapping.containsKey(type)) + return _ConversationFileMimeTypeMapping[type]; + else + return ConversationMessageFileType.OTHER; + } + + /// Get the icon associated with file type + IconData get icon { + switch (fileType) { + case ConversationMessageFileType.IMAGE: + return Icons.image; + case ConversationMessageFileType.VIDEO: + return Icons.video_library; + case ConversationMessageFileType.AUDIO: + return Icons.audiotrack; + case ConversationMessageFileType.PDF: + return Icons.picture_as_pdf; + + case ConversationMessageFileType.ZIP: + return Icons.archive; + + default: + return Icons.insert_drive_file; + } + } + + bool get hasThumbnail => thumbnail != null; + + bool get downloadable => fileType == ConversationMessageFileType.AUDIO; + Map toJson() => { "url": url, "size": size, @@ -165,10 +218,6 @@ class ConversationMessage extends SerializableElement { bool get hasFile => file != null; - bool get hasThumbnail => hasFile && file.thumbnail != null; - - bool get hasImage => hasFile && file.type.startsWith("image/"); - bool get isOwner => account.userID() == userID; bool get isServerMessage => serverMessage != null; @@ -203,6 +252,10 @@ class ConversationMessage extends SerializableElement { userID = map["userID"], timeSent = map["timeSent"], message = DisplayedString(map["message"]), - file = map["file"], - serverMessage = map["serverMessage"]; + file = map["file"] == null + ? null + : ConversationMessageFile.fromJson(map["file"]), + serverMessage = map["serverMessage"] == null + ? null + : ConversationServerMessage.fromJson(map["serverMessage"]); } diff --git a/lib/ui/routes/full_screen_image.dart b/lib/ui/routes/full_screen_image.dart index edde6e6..5db7b52 100644 --- a/lib/ui/routes/full_screen_image.dart +++ b/lib/ui/routes/full_screen_image.dart @@ -2,6 +2,7 @@ import 'package:cached_network_image/cached_network_image.dart'; import 'package:comunic/utils/intl_utils.dart'; import 'package:flutter/material.dart'; import 'package:photo_view/photo_view.dart'; +import 'package:url_launcher/url_launcher.dart'; /// Full screen image details /// @@ -22,6 +23,10 @@ class _FullScreenImageRouteState extends State { return Scaffold( appBar: AppBar( title: Text(tr("Image")), + actions: [ + IconButton( + icon: Icon(Icons.launch), onPressed: () => launch(widget.url)) + ], ), body: PhotoView(imageProvider: CachedNetworkImageProvider(widget.url)), ); diff --git a/lib/ui/tiles/conversation_message_tile.dart b/lib/ui/tiles/conversation_message_tile.dart index 444be58..0bcee3b 100644 --- a/lib/ui/tiles/conversation_message_tile.dart +++ b/lib/ui/tiles/conversation_message_tile.dart @@ -1,6 +1,7 @@ import 'package:comunic/models/conversation_message.dart'; import 'package:comunic/models/user.dart'; import 'package:comunic/ui/widgets/account_image_widget.dart'; +import 'package:comunic/ui/widgets/conversation_file_tile.dart'; import 'package:comunic/ui/widgets/text_widget.dart'; import 'package:comunic/utils/date_utils.dart'; import 'package:comunic/utils/intl_utils.dart'; @@ -70,19 +71,10 @@ class ConversationMessageTile extends StatelessWidget { } /// Build widget image - Widget _buildMessageImage(BuildContext context) { - return Text(""); - // TODO : fix file - /*return Container( - margin: EdgeInsets.only(bottom: 2), - child: NetworkImageWidget( - url: message.imageURL, - allowFullScreen: true, - width: 200, - height: 200, - ), - );*/ - } + Widget _buildMessageFile(BuildContext context) => ConversationFileWidget( + messageID: message.id, + file: message.file, + ); /// Build message date Widget _buildMessageDate() { @@ -113,9 +105,8 @@ class ConversationMessageTile extends StatelessWidget { children: [ // Text image Container( - child: message.hasImage - ? _buildMessageImage(context) - : null, + child: + message.hasFile ? _buildMessageFile(context) : null, ), // Text message @@ -190,8 +181,7 @@ class ConversationMessageTile extends StatelessWidget { children: [ // Text image Container( - child: - message.hasImage ? _buildMessageImage(context) : null, + child: message.hasFile ? _buildMessageFile(context) : null, ), // Text message diff --git a/lib/ui/widgets/audio_player_widget.dart b/lib/ui/widgets/audio_player_widget.dart new file mode 100644 index 0000000..a9b43bc --- /dev/null +++ b/lib/ui/widgets/audio_player_widget.dart @@ -0,0 +1,135 @@ +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:comunic/utils/date_utils.dart'; +import 'package:flutter/material.dart'; + +/// Audio player widget +/// +/// @author Pierre Hubert + +class AudioPlayerWidget extends StatefulWidget { + final File file; + + const AudioPlayerWidget(this.file); + + @override + _AudioPlayerWidgetState createState() => _AudioPlayerWidgetState(); +} + +class _AudioPlayerWidgetState extends State { + AudioPlayer _player; + + Duration _mediaDuration; + Duration _mediaPosition; + + double get _max => _mediaDuration?.inMilliseconds?.toDouble() ?? 0.0; + + double get _value => _mediaPosition?.inMilliseconds?.toDouble() ?? 0.0; + + bool get _playing => + _player != null && _player.state == AudioPlayerState.PLAYING; + + @override + void dispose() { + super.dispose(); + + if (_player != null) _player.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + textStyle: TextStyle(color: Colors.white), + color: Colors.transparent, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Spacer(), + Icon(Icons.audiotrack, color: Colors.white), + Spacer(), + Slider( + value: _value, + onChanged: (newValue) => + _player.seek(Duration(milliseconds: newValue.toInt())), + max: _max, + activeColor: Colors.white, + min: 0, + ), + Spacer(), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Spacer(), + Text(formatDuration(_mediaPosition ?? Duration())), + Spacer(), + _AudioButton( + icon: Icons.play_arrow, onTap: _play, visible: !_playing), + _AudioButton(icon: Icons.pause, onTap: _pause, visible: _playing), + _AudioButton(icon: Icons.stop, onTap: _stop, visible: _playing), + Spacer(), + Text(formatDuration(_mediaDuration ?? Duration())), + Spacer(), + ], + ), + Spacer(), + ], + ), + ); + } + + void _play() async { + if (_player == null) { + _player = AudioPlayer(); + + _player.onDurationChanged.listen((newDuration) { + setState(() => _mediaDuration = newDuration); + }); + + _player.onAudioPositionChanged.listen((newDuration) { + setState(() => _mediaPosition = newDuration); + }); + + _player.onPlayerStateChanged.listen((event) => setState(() {})); + + _player.onSeekComplete.listen((event) => setState(() {})); + } + + if (_player.state != AudioPlayerState.PAUSED) + _player.play(widget.file.absolute.path, isLocal: true); + else + _player.resume(); + } + + void _pause() async { + _player.pause(); + } + + void _stop() { + _player.stop(); + _player.seek(Duration()); + } +} + +class _AudioButton extends StatelessWidget { + final IconData icon; + final void Function() onTap; + final bool visible; + + const _AudioButton({ + Key key, + @required this.icon, + @required this.onTap, + @required this.visible, + }) : assert(icon != null), + assert(onTap != null), + assert(visible != null), + super(key: key); + + @override + Widget build(BuildContext context) { + if (!visible) return Container(); + + return IconButton(icon: Icon(icon, color: Colors.white), onPressed: onTap); + } +} diff --git a/lib/ui/widgets/conversation_file_tile.dart b/lib/ui/widgets/conversation_file_tile.dart new file mode 100644 index 0000000..b5f9404 --- /dev/null +++ b/lib/ui/widgets/conversation_file_tile.dart @@ -0,0 +1,200 @@ +/// Chat file tile +/// +/// @author Pierre Hubert +import 'dart:io'; + +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:comunic/helpers/conversation_files_helper.dart'; +import 'package:comunic/models/conversation_message.dart'; +import 'package:comunic/ui/widgets/async_screen_widget.dart'; +import 'package:comunic/ui/widgets/audio_player_widget.dart'; +import 'package:comunic/ui/widgets/network_image_widget.dart'; +import 'package:comunic/utils/intl_utils.dart'; +import 'package:comunic/utils/log_utils.dart'; +import 'package:comunic/utils/ui_utils.dart'; +import 'package:dio/dio.dart'; +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +const _AreaSize = 150.0; + +class ConversationFileWidget extends StatefulWidget { + final int messageID; + final ConversationMessageFile file; + + const ConversationFileWidget({ + Key key, + @required this.messageID, + @required this.file, + }) : assert(messageID != null), + assert(file != null), + super(key: key); + + @override + _ConversationFileWidgetState createState() => _ConversationFileWidgetState(); +} + +class _ConversationFileWidgetState extends State { + final _refreshKey = GlobalKey(); + + File _targetFile; + + bool _isDownloaded; + + bool _downloading = false; + var _downloadProgress = 0.0; + CancelToken _cancelDownloadToken; + + ConversationMessageFile get file => widget.file; + + Future _refresh() async { + _targetFile = await ConversationFilesHelper.getPathForChatFile( + widget.messageID, file); + + _isDownloaded = await _targetFile.exists(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: _AreaSize, + height: _AreaSize, + child: AsyncScreenWidget( + key: _refreshKey, + onReload: _refresh, + onBuild: _buildContent, + errorMessage: tr("Error!"), + ), + ); + } + + Widget _buildContent() => _isDownloaded || !file.downloadable + ? _buildFileWidget() + : _buildDownloadWidget(); + + Widget _buildDownloadWidget() => Stack( + children: [ + // Thumbnail, if possible + !file.hasThumbnail + ? Container() + : CachedNetworkImage( + imageUrl: file.thumbnail, + width: _AreaSize, + height: _AreaSize, + fit: BoxFit.fill, + ), + + Container( + width: _AreaSize, + color: Color(0x66000000), + child: DefaultTextStyle( + style: TextStyle(color: Colors.white), + child: Column( + children: [ + Spacer(), + Icon(file.icon, color: Colors.white), + Spacer(), + _buildDownloadArea(), + Spacer(), + Text(filesize(file.size)), + Spacer(), + ], + ), + ), + ) + ], + ); + + Widget _buildDownloadArea() => _downloading + ? _buildDownloadingWidget() + : Material( + borderRadius: BorderRadius.all(Radius.circular(2.0)), + color: Colors.green, + child: IconButton( + icon: Icon(Icons.file_download), + onPressed: _downloadFile, + color: Colors.white, + ), + ); + + Widget _buildDownloadingWidget() => Container( + width: 36, + height: 36, + child: Stack( + children: [ + CircularProgressIndicator(value: _downloadProgress), + Center( + child: InkWell( + onTap: () => _cancelDownloadToken.cancel(), + child: Icon(Icons.cancel, color: Colors.white), + ), + ) + ], + ), + ); + + Future _downloadFile() async { + try { + setState(() { + _cancelDownloadToken = CancelToken(); + _downloading = true; + _downloadProgress = 0.0; + }); + + await ConversationFilesHelper.download( + msgID: widget.messageID, + fileInfo: file, + onProgress: (p) => setState(() => _downloadProgress = p), + cancelToken: _cancelDownloadToken, + ); + + await _refreshKey.currentState.refresh(); + } catch (e, s) { + logError(e, s); + showSimpleSnack(context, tr("Failed to download file!")); + } + + setState(() { + _downloading = false; + }); + } + + Widget _buildFileWidget() { + switch (file.fileType) { + // Images + case ConversationMessageFileType.IMAGE: + return Center( + child: NetworkImageWidget( + url: file.url, + thumbnailURL: file.thumbnail, + allowFullScreen: true, + ), + ); + + // Audio player + case ConversationMessageFileType.AUDIO: + return AudioPlayerWidget(_targetFile); + + // The file is not downloadable, we open it in the browser + default: + return Center( + child: MaterialButton( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Spacer(flex: 2), + Icon(file.icon, color: Colors.white), + Spacer(), + Text(file.name, textAlign: TextAlign.center), + Spacer(flex: 2), + ], + ), + onPressed: () => launch(file.url), + ), + ); + break; + } + } +} diff --git a/lib/ui/widgets/network_image_widget.dart b/lib/ui/widgets/network_image_widget.dart index 2141c1e..c957bb8 100644 --- a/lib/ui/widgets/network_image_widget.dart +++ b/lib/ui/widgets/network_image_widget.dart @@ -10,6 +10,7 @@ import 'package:flutter/material.dart'; class NetworkImageWidget extends StatelessWidget { final String url; + final String thumbnailURL; final bool allowFullScreen; final bool roundedEdges; final double width; @@ -19,6 +20,7 @@ class NetworkImageWidget extends StatelessWidget { const NetworkImageWidget({ Key key, @required this.url, + this.thumbnailURL, this.allowFullScreen = false, this.width, this.height, @@ -42,7 +44,7 @@ class NetworkImageWidget extends StatelessWidget { } : null, child: CachedNetworkImage( - imageUrl: url, + imageUrl: thumbnailURL ?? url, width: width, height: height, fit: BoxFit.cover, diff --git a/lib/utils/date_utils.dart b/lib/utils/date_utils.dart index d08b38f..5a4cbb9 100644 --- a/lib/utils/date_utils.dart +++ b/lib/utils/date_utils.dart @@ -39,16 +39,20 @@ String diffTimeToStr(int amount) { // Years final years = (amount / (60 * 60 * 24 * 365)).floor(); - return years == 1 ? tr("1 year") : tr("%years% years", - args: {"years": years.toString()}); + return years == 1 + ? tr("1 year") + : tr("%years% years", args: {"years": years.toString()}); } String diffTimeFromNowToStr(int date) { return diffTimeToStr(time() - date); } - /// Return properly formatted date and time String dateTimeToString(DateTime time) { return "${time.day}:${time.month}:${time.year} ${time.hour}:${time.minute}"; -} \ No newline at end of file +} + +/// Format a [Duration] in the form "MM:SS" +String formatDuration(Duration d) => + "${d.inMinutes < 10 ? "0" + d.inMinutes.toString() : d.inMinutes.toString()}:${d.inSeconds % 60 < 10 ? "0" + (d.inSeconds % 60).toString() : (d.inSeconds % 60).toString()}"; diff --git a/lib/utils/log_utils.dart b/lib/utils/log_utils.dart new file mode 100644 index 0000000..2de4e89 --- /dev/null +++ b/lib/utils/log_utils.dart @@ -0,0 +1,11 @@ +/// Log utilities +/// +/// @author Pierre Hubert + +void logError(dynamic e, StackTrace s) { + if (e is Exception) { + print("Exception: $e\n$s"); + } else { + print("Error: $e\n$s"); + } +} diff --git a/pubspec.lock b/pubspec.lock index acd0645..f3d400d 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -22,6 +22,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "2.5.0-nullsafety.1" + audioplayers: + dependency: "direct main" + description: + name: audioplayers + url: "https://pub.dartlang.org" + source: hosted + version: "0.15.1" boolean_selector: dependency: transitive description: @@ -183,6 +190,13 @@ packages: url: "https://pub.dartlang.org" source: hosted version: "4.2.8" + filesize: + dependency: "direct main" + description: + name: filesize + url: "https://pub.dartlang.org" + source: hosted + version: "1.0.4" flutter: dependency: "direct main" description: flutter diff --git a/pubspec.yaml b/pubspec.yaml index 31cd5d1..262f079 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,8 +91,15 @@ dependencies: # Version manager version: ^1.2.0 + # Play audio files + audioplayers: ^0.15.1 + + # Get path to temporary files path_provider: ^1.6.27 + # Format file size + filesize: ^1.0.4 + dev_dependencies: flutter_test: sdk: flutter