1
0
mirror of https://gitlab.com/comunic/comunicmobile synced 2024-11-22 12:59:21 +00:00

Show message files

This commit is contained in:
Pierre HUBERT 2021-03-11 00:02:41 +01:00
parent 2989e98c50
commit 6c00e0bcab
12 changed files with 498 additions and 30 deletions

View File

@ -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<File> 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<void> 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()),
);
}
}

View File

@ -68,7 +68,8 @@ class Conversation extends SerializableElement<Conversation> {
lastActivity = map["lastActivity"],
members = map["members"]
.map((el) => ConversationMember.fromJSON(el))
.toList(),
.toList()
.cast<ConversationMember>(),
canEveryoneAddMembers = map["canEveryoneAddMembers"],
// By default, we can not do any call

View File

@ -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<String, dynamic> toJson() => {
"url": url,
"size": size,
@ -165,10 +218,6 @@ class ConversationMessage extends SerializableElement<ConversationMessage> {
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<ConversationMessage> {
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"]);
}

View File

@ -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<FullScreenImageRoute> {
return Scaffold(
appBar: AppBar(
title: Text(tr("Image")),
actions: [
IconButton(
icon: Icon(Icons.launch), onPressed: () => launch(widget.url))
],
),
body: PhotoView(imageProvider: CachedNetworkImageProvider(widget.url)),
);

View File

@ -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: <Widget>[
// 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: <Widget>[
// Text image
Container(
child:
message.hasImage ? _buildMessageImage(context) : null,
child: message.hasFile ? _buildMessageFile(context) : null,
),
// Text message

View File

@ -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<AudioPlayerWidget> {
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: <Widget>[
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: <Widget>[
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);
}
}

View File

@ -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<ConversationFileWidget> {
final _refreshKey = GlobalKey<AsyncScreenWidgetState>();
File _targetFile;
bool _isDownloaded;
bool _downloading = false;
var _downloadProgress = 0.0;
CancelToken _cancelDownloadToken;
ConversationMessageFile get file => widget.file;
Future<void> _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: <Widget>[
// 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: <Widget>[
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: <Widget>[
CircularProgressIndicator(value: _downloadProgress),
Center(
child: InkWell(
onTap: () => _cancelDownloadToken.cancel(),
child: Icon(Icons.cancel, color: Colors.white),
),
)
],
),
);
Future<void> _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;
}
}
}

View File

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

View File

@ -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}";
}
}
/// 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()}";

11
lib/utils/log_utils.dart Normal file
View File

@ -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");
}
}

View File

@ -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

View File

@ -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