diff --git a/lib/helpers/server_config_helper.dart b/lib/helpers/server_config_helper.dart index 47a7ed6..195706d 100644 --- a/lib/helpers/server_config_helper.dart +++ b/lib/helpers/server_config_helper.dart @@ -70,3 +70,6 @@ class ServerConfigurationHelper { return _config; } } + +/// Shortcut for server configuration +ServerConfig get srvConfig => ServerConfigurationHelper.config; \ No newline at end of file diff --git a/lib/ui/dialogs/pick_file_dialog.dart b/lib/ui/dialogs/pick_file_dialog.dart new file mode 100644 index 0000000..927887b --- /dev/null +++ b/lib/ui/dialogs/pick_file_dialog.dart @@ -0,0 +1,161 @@ +import 'package:comunic/models/api_request.dart'; +import 'package:comunic/utils/files_utils.dart'; +import 'package:comunic/utils/intl_utils.dart'; +import 'package:comunic/utils/ui_utils.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:mime/mime.dart'; + +/// Pick file dialog +/// +/// @author Pierre Hubert + +enum _FileChoices { + PICK_IMAGE, + TAKE_PICTURE, + PICK_OTHER_FILE, +} + +typedef _CanEnable = bool Function(List); +typedef _OnOptionSelected = void Function(_FileChoices); + +class _PickFileOption { + final _FileChoices value; + final String label; + final IconData icon; + final _CanEnable canEnable; + + const _PickFileOption({ + @required this.value, + @required this.label, + @required this.icon, + @required this.canEnable, + }) : assert(value != null), + assert(label != null), + assert(icon != null), + assert(canEnable != null); +} + +List<_PickFileOption> get _optionsList => [ + // Image + _PickFileOption( + value: _FileChoices.PICK_IMAGE, + label: tr("Choose an image"), + icon: Icons.image_search, + canEnable: (l) => l.any(isImage)), + _PickFileOption( + value: _FileChoices.TAKE_PICTURE, + label: tr("Take a picture"), + icon: Icons.camera_alt, + canEnable: (l) => l.any(isImage)), + _PickFileOption( + value: _FileChoices.PICK_OTHER_FILE, + label: tr("Browse files"), + icon: Icons.folder_open, + canEnable: (l) => l.any((el) => !isImage(el))), + ]; + +Future showPickFileDialog({ + @required BuildContext context, + int maxFileSize, + List allowedMimeTypes, + double imageMaxWidth, + double imageMaxHeight, +}) async { + assert(allowedMimeTypes != null); + + // Get the list of allowed extension + final allowedExtensions = List(); + for (var mime in allowedExtensions) { + final ext = extensionFromMime(mime); + if (ext != mime) allowedExtensions.add(ext); + } + + // Display bottom sheet + final choice = await showModalBottomSheet( + context: context, + builder: (c) => BottomSheet( + onClosing: () {}, + builder: (c) => _BottomSheetPickOption( + options: _optionsList + .where((element) => element.canEnable(allowedMimeTypes)) + .toList(), + onOptionSelected: (v) => Navigator.pop(c, v), + ), + )); + + if (choice == null) return null; + + BytesFile file; + switch (choice) { + // Pick an image + case _FileChoices.PICK_IMAGE: + case _FileChoices.TAKE_PICTURE: + final image = await ImagePicker().getImage( + source: choice == _FileChoices.PICK_IMAGE + ? ImageSource.gallery + : ImageSource.camera, + maxWidth: imageMaxWidth, + maxHeight: imageMaxHeight, + ); + + if (image == null) return null; + + file = BytesFile(image.path.split("/").last, await image.readAsBytes()); + + break; + + // Pick other files + case _FileChoices.PICK_OTHER_FILE: + final pickedFile = await FilePicker.platform.pickFiles( + type: FileType.any, + allowedExtensions: allowedExtensions, + allowMultiple: false, + withData: true, + ); + + if (pickedFile == null || pickedFile.files.length == 0) return null; + + file = BytesFile(pickedFile.files[0].name, pickedFile.files[0].bytes); + break; + } + + if (file == null) return null; + + // Check file size + if (file.bytes.length > maxFileSize) { + showSimpleSnack( + context, + tr("This file could not be sent: it is too big! (Max allowed size: %1%)", + args: {"1": filesize(file.bytes.length)})); + return null; + } + + return file; +} + +class _BottomSheetPickOption extends StatelessWidget { + final List<_PickFileOption> options; + final _OnOptionSelected onOptionSelected; + + const _BottomSheetPickOption( + {Key key, @required this.options, @required this.onOptionSelected}) + : assert(options != null), + assert(onOptionSelected != null), + super(key: key); + + @override + Widget build(BuildContext context) => Container( + height: 300, + child: ListView.builder( + itemCount: options.length, + itemBuilder: (c, i) => ListTile( + leading: Icon(options[i].icon), + title: Text(options[i].label), + onTap: () => onOptionSelected(options[i].value), + ), + ), + ); +} diff --git a/lib/ui/screens/conversation_screen.dart b/lib/ui/screens/conversation_screen.dart index b555a06..8a4b86e 100644 --- a/lib/ui/screens/conversation_screen.dart +++ b/lib/ui/screens/conversation_screen.dart @@ -6,16 +6,15 @@ import 'package:comunic/helpers/server_config_helper.dart'; import 'package:comunic/helpers/users_helper.dart'; import 'package:comunic/lists/conversation_messages_list.dart'; import 'package:comunic/lists/users_list.dart'; -import 'package:comunic/models/api_request.dart'; import 'package:comunic/models/conversation.dart'; import 'package:comunic/models/conversation_message.dart'; import 'package:comunic/models/new_conversation_message.dart'; +import 'package:comunic/ui/dialogs/pick_file_dialog.dart'; import 'package:comunic/ui/routes/main_route/main_route.dart'; import 'package:comunic/ui/tiles/conversation_message_tile.dart'; import 'package:comunic/ui/tiles/server_conversation_message_tile.dart'; import 'package:comunic/ui/widgets/safe_state.dart'; import 'package:comunic/ui/widgets/scroll_watcher.dart'; -import 'package:comunic/utils/files_utils.dart'; import 'package:comunic/utils/intl_utils.dart'; import 'package:comunic/utils/list_utils.dart'; import 'package:comunic/utils/log_utils.dart'; @@ -221,49 +220,42 @@ class _ConversationScreenState extends SafeState { _setError(ErrorLevel.NONE); } - /// Pick and send an image - Future _sendImage(BuildContext context) async { + /// Send a file message + Future _sendFileMessage() async { try { - final image = await pickImage(context); + final file = await showPickFileDialog( + context: context, + maxFileSize: srvConfig.conversationsPolicy.filesMaxSize, + allowedMimeTypes: srvConfig.conversationsPolicy.allowedFilesType, + ); - if (image == null) return; + if (file == null) return; - await _sendFileMessage(BytesFile( - image.path.split("/").last, - await image.readAsBytes(), - )); + await _submitMessage( + NewConversationMessage( + conversationID: widget.conversationID, + message: null, + file: file, + ), + ); } catch (e, s) { logError(e, s); - showSimpleSnack(context, tr("Failed to send image!")); + showSimpleSnack(context, tr("Failed to send a file!")); } } - /// Send a file message - Future _sendFileMessage(BytesFile file) async { - await _submitMessage( - context, - NewConversationMessage( - conversationID: widget.conversationID, - message: null, - file: file, - ), - ); - } - /// Send a new text message - Future _submitTextMessage(BuildContext context, String content) async { - if (await _submitMessage( - context, - NewConversationMessage( - conversationID: widget.conversationID, - message: content, - )) == + Future _submitTextMessage() async { + if (await _submitMessage(NewConversationMessage( + conversationID: widget.conversationID, + message: textMessage, + )) == SendMessageResult.SUCCESS) _clearSendMessageForm(); } /// Submit a new message Future _submitMessage( - BuildContext context, NewConversationMessage message) async { + NewConversationMessage message) async { //Send the message _setSending(true); final result = await _conversationsHelper.sendMessage(message); @@ -368,7 +360,7 @@ class _ConversationScreenState extends SafeState { ? Theme.of(context).disabledColor : Theme.of(context).accentColor, ), - onPressed: () => _sendImage(context), + onPressed: () => _sendFileMessage(), ), ), @@ -387,9 +379,7 @@ class _ConversationScreenState extends SafeState { enabled: !_isSendingMessage, controller: _textEditingController, onChanged: (s) => setState(() {}), - onSubmitted: _isMessageValid - ? (s) => _submitTextMessage(context, s) - : null, + onSubmitted: _isMessageValid ? (s) => _submitTextMessage() : null, decoration: new InputDecoration.collapsed( hintText: tr("Send a message"), ), @@ -407,8 +397,7 @@ class _ConversationScreenState extends SafeState { : Theme.of(context).disabledColor, ), onPressed: !_isSendingMessage && _isMessageValid - ? () => - _submitTextMessage(context, _textEditingController.text) + ? () => _submitTextMessage() : null, ), ), diff --git a/lib/utils/files_utils.dart b/lib/utils/files_utils.dart index dd4b0c0..c6c2a3d 100644 --- a/lib/utils/files_utils.dart +++ b/lib/utils/files_utils.dart @@ -45,3 +45,6 @@ Future pickImage(BuildContext context) async { ? ImageSource.camera : ImageSource.gallery); } + +/// Check if a mime type maps to an image or not +bool isImage(String mimeType) => mimeType.startsWith("image/"); diff --git a/pubspec.lock b/pubspec.lock index fc39f68..b9f3175 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -191,7 +191,7 @@ packages: source: hosted version: "0.1.6" file_picker: - dependency: transitive + dependency: "direct main" description: name: file_picker url: "https://pub.dartlang.org" @@ -346,7 +346,7 @@ packages: source: hosted version: "1.3.0-nullsafety.3" mime: - dependency: transitive + dependency: "direct main" description: name: mime url: "https://pub.dartlang.org" diff --git a/pubspec.yaml b/pubspec.yaml index b70a168..20c5ee9 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -83,6 +83,7 @@ dependencies: wakelock: ^0.2.1+1 # Pick any kind of file + file_picker: ^2.1.6 file_picker_cross: ^4.2.8 # Get information about current version @@ -105,6 +106,9 @@ dependencies: chewie_audio: ^1.1.2 chewie: ^0.12.2 + # Determine file mime type + mime: ^0.9.7 + dev_dependencies: flutter_test: sdk: flutter