import 'dart:async'; import 'package:comunic/helpers/conversations_helper.dart'; import 'package:comunic/helpers/events_helper.dart'; 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/config.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/account_image_widget.dart'; import 'package:comunic/ui/widgets/safe_state.dart'; import 'package:comunic/ui/widgets/scroll_watcher.dart'; import 'package:comunic/ui/widgets/user_writing_in_conv_notifier.dart'; import 'package:comunic/utils/account_utils.dart'; import 'package:comunic/utils/date_utils.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'; import 'package:comunic/utils/ui_utils.dart'; import 'package:comunic/utils/video_utils.dart'; import 'package:dio/dio.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emoji; import 'package:flutter/material.dart'; import 'package:mime/mime.dart'; /// Conversation screen /// /// @author Pierre HUBERT enum ErrorLevel { NONE, MINOR, MAJOR } enum _OlderMessagesLevel { NONE, LOADING, NO_MORE_AVAILABLE } class ConversationScreen extends StatefulWidget { final int conversationID; const ConversationScreen({Key key, this.conversationID}) : assert(conversationID != null), super(key: key); @override State createState() => _ConversationScreenState(); } class _ConversationScreenState extends SafeState { //Helpers final ConversationsHelper _conversationsHelper = ConversationsHelper(); final UsersHelper _usersHelper = UsersHelper(); // Class members Conversation _conversation; ConversationMessagesList _messages; UsersList _usersInfo = UsersList(); ErrorLevel _error = ErrorLevel.NONE; final _textFieldFocus = FocusNode(); bool _showEmojiPicker = false; bool _isSendingMessage = false; TextEditingController _textController = TextEditingController(); ScrollWatcher _scrollController; _OlderMessagesLevel _loadingOlderMessages = _OlderMessagesLevel.NONE; int _lastWritingEventSent = 0; CancelToken _sendCancel; double _sendProgress; String get textMessage => _textController.text; bool get _isMessageValid => textMessage.length >= ServerConfigurationHelper.config.conversationsPolicy.minMessageLen && textMessage.length < ServerConfigurationHelper.config.conversationsPolicy.maxMessageLen; showKeyboard() => _textFieldFocus.requestFocus(); hideKeyboard() => _textFieldFocus.unfocus(); hideEmojiContainer() => setState(() => _showEmojiPicker = false); showEmojiContainer() => setState(() => _showEmojiPicker = true); // Colors definition Color get _senderColor => _conversation.color ?? config().defaultConversationColor ?? /*(darkTheme() ? Color(0xff2b343b) :*/ Colors.blue.shade700; //); Color get _receiverColor => darkTheme() ? Color(0xff3a3d40) : Colors.grey.shade600; Color get _greyColor => Color(0xff8f8f8f); Color get _gradientColorStart => (_conversation.color ?? config().defaultConversationColor) ?.withOpacity(0.7) ?? (darkTheme() ? Color(0xff00b6f3) : Colors.blue.shade300); Color get _gradientColorEnd => _conversation.color ?? config().defaultConversationColor ?? (darkTheme() ? Color(0xff0184dc) : Colors.blueAccent.shade700); Color get _separatorColor => darkTheme() ? Color(0xff272c35) : Color(0xffBEBEBE); LinearGradient get _fabGradient => LinearGradient( colors: [_gradientColorStart, _gradientColorEnd], begin: Alignment.topLeft, end: Alignment.bottomRight, ); LinearGradient get _disabledGradient => LinearGradient( colors: [_greyColor, _receiverColor], begin: Alignment.topLeft, end: Alignment.bottomRight, ); @override void initState() { super.initState(); _init(); } @override void dispose() { super.dispose(); _deallocate(); } void _setError(ErrorLevel err) => setState(() => _error = err); void _setSending(bool sending) => setState(() => _isSendingMessage = sending); void _setLoadingOlderMessagesState(_OlderMessagesLevel state) => setState(() { _loadingOlderMessages = state; }); /// Method called when an error occurred while loading messages void _errorLoading() => _setError(_messages == null ? ErrorLevel.MAJOR : ErrorLevel.MINOR); /// Load the first conversations Future _init() async { _scrollController = ScrollWatcher(onReachBottom: _loadOlderMessages); _conversation = await ConversationsHelper().getSingle(widget.conversationID); // Fetch latest messages await _loadMessages(false); await _loadMessages(true); await _conversationsHelper .registerConversationEvents(widget.conversationID); this.listen((ev) async { if (ev.msg.convID == widget.conversationID) { try { await _conversationsHelper.saveMessage(ev.msg); await _applyNewMessages(ConversationMessagesList()..add(ev.msg)); } catch (e, s) { print("Failed to show new message! $e => $s"); _errorLoading(); } } }); this.listen((ev) async { if (ev.msg.convID == widget.conversationID) { await _conversationsHelper.saveMessage(ev.msg); setState(() => _messages.replace(ev.msg)); } }); this.listen((ev) async { if (ev.msg.convID == widget.conversationID) { await _conversationsHelper.removeMessage(ev.msg); setState(() => _messages.removeMsg(ev.msg.id)); } }); this.listen((ev) { if (ev.userID == userID() && ev.convID == widget.conversationID) { setState(() => _error = ErrorLevel.MAJOR); } }); this.listen((ev) { if (ev.convID == widget.conversationID) { setState(() => _error = ErrorLevel.MAJOR); } }); } /// Free resources when this conversation widget is no longer required void _deallocate() { _conversationsHelper.unregisterConversationEvents(widget.conversationID); } /// Load a list of messages Future _loadMessages(bool online) async { if (!mounted) return; try { //First, get the messages final messages = await _conversationsHelper.getNewMessages( conversationID: widget.conversationID, lastMessageID: _messages == null ? 0 : _messages.lastMessageID, online: online, ); // In case we are offline and we did not get any message we do not do // anything (we wait for the online request) if (messages.length == 0 && !online) return; await _applyNewMessages(messages); } catch (e, s) { debugPrint("Failed to load messages! $e => $s", wrapWidth: 4096); _errorLoading(); } } /// Get older messages Future _loadOlderMessages() async { if (_loadingOlderMessages != _OlderMessagesLevel.NONE || _messages == null || _messages.length == 0) return; try { // Let's start to load older messages _setLoadingOlderMessagesState(_OlderMessagesLevel.LOADING); final messages = await _conversationsHelper.getOlderMessages( conversationID: widget.conversationID, oldestMessagesID: _messages.firstMessageID); // Mark as not loading anymore _setLoadingOlderMessagesState(_OlderMessagesLevel.NONE); // Check if there is no more unread messages if (messages.length == 0) { _setLoadingOlderMessagesState(_OlderMessagesLevel.NO_MORE_AVAILABLE); return; } // Apply the messages _applyNewMessages(messages); } catch (e, s) { print("Failed to load older messages! $e => $s"); _errorLoading(); } } /// Apply new messages [messages] must not be null /// /// Throws in case of failure Future _applyNewMessages(ConversationMessagesList messages) async { // We ignore new messages once the area is no longer visible if (!this.mounted) return; //Then get information about users final usersToGet = findMissingFromSet(_usersInfo.usersID.toSet(), messages.getUsersID()); final users = await _usersHelper.getList(usersToGet); // Save the new list of messages setState(() { _usersInfo.addAll(users); if (_messages == null) _messages = messages; else _messages.addAll(messages); //Reverse the order of the messages (if required) if (messages.length > 0) { _messages.sort(); final reverse = _messages.reversed; _messages = ConversationMessagesList(); _messages.addAll(reverse); } }); // Remove previous errors _setError(ErrorLevel.NONE); } /// Send a file message Future _sendFileMessage() async { try { final file = await showPickFileDialog( context: context, maxFileSize: srvConfig.conversationsPolicy.filesMaxSize, allowedMimeTypes: srvConfig.conversationsPolicy.allowedFilesType, imageMaxWidth: srvConfig.conversationsPolicy.maxMessageImageWidth, imageMaxHeight: srvConfig.conversationsPolicy.maxMessageImageHeight, ); if (file == null) return; BytesFile thumbnail; if (isVideo(lookupMimeType(file.filename))) thumbnail = await generateVideoThumbnail( videoFile: file, maxWidth: srvConfig.conversationsPolicy.maxThumbnailWidth, ); _sendCancel = CancelToken(); final progressCb = (count, total) => setState(() => _sendProgress = count / total); final res = await ConversationsHelper().sendMessage( NewConversationMessage( conversationID: widget.conversationID, message: null, file: file, thumbnail: thumbnail), sendProgress: progressCb, cancelToken: _sendCancel, ); assert(res == SendMessageResult.SUCCESS); } catch (e, s) { logError(e, s); showSimpleSnack(context, tr("Failed to send a file!")); } setState(() { _sendCancel = null; _sendProgress = null; }); } /// Send a new text message Future _submitTextMessage() async { if (await _submitMessage(NewConversationMessage( conversationID: widget.conversationID, message: textMessage, )) == SendMessageResult.SUCCESS) _clearSendMessageForm(); } /// Submit a new message Future _submitMessage( NewConversationMessage message) async { //Send the message _setSending(true); final result = await _conversationsHelper.sendMessage(message); _setSending(false); //Check the result of the operation if (result != SendMessageResult.SUCCESS) ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( result == SendMessageResult.MESSAGE_REJECTED ? tr("Message rejected by the server!") : tr("Could not send message!"), ), duration: Duration(milliseconds: 500), ), ); return result; } /// Clear send message form void _clearSendMessageForm() { setState(() => _textController = TextEditingController()); } /// Error handling Widget _buildError() { return buildErrorCard(tr("Could not load the list of messages!")); } /// Widget shown when loading older messages Widget _buildLoadingOlderMessage() { return Container( padding: EdgeInsets.all(8.0), child: CircularProgressIndicator(), ); } /// Notice shown when there is no messages to show Widget _buildNoMessagesNotice() { return Expanded( child: Center( child: Text(tr("There is no message yet in this conversation.")), ), ); } /// Messages list Widget _buildMessagesList() { return Expanded( child: ListView.builder( controller: _scrollController, reverse: true, itemCount: _messages.length, itemBuilder: (c, i) => _buildMessageItem(i), )); } Widget _buildMessageItem(int msgIndex) { final msg = _messages[msgIndex]; final nextMessage = msgIndex + 1 < _messages.length ? _messages[msgIndex + 1] : null; return Column( children: [ Container( child: !isSameDate(msg.date, nextMessage?.date) ? _buildDateWidget(msg.date) : null, ), msg.isServerMessage ? Container( alignment: Alignment.center, child: ServerConversationMessageTile( message: msg.serverMessage, users: _usersInfo), ) : Container( margin: EdgeInsets.symmetric(vertical: 5), alignment: msg.isOwner ? Alignment.centerRight : Alignment.centerLeft, child: msg.isOwner ? _buildSenderLayout(msg, nextMessage) : _buildReceiverLayout(msg, nextMessage), ), ], ); } Widget _buildSenderLayout( ConversationMessage message, ConversationMessage previousMessage) { final messageRadius = Radius.circular(10); return Container( margin: EdgeInsets.only( top: previousMessage?.isOwner == true ? 0 : 12, right: 5), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.65), decoration: BoxDecoration( color: _senderColor, borderRadius: BorderRadius.only( topLeft: messageRadius, topRight: messageRadius, bottomLeft: messageRadius, ), ), child: Padding( padding: EdgeInsets.all(10), child: _buildMessage(message), ), ); } Widget _buildReceiverLayout( ConversationMessage message, ConversationMessage previousMessage) { final messageRadius = Radius.circular(10); return Row(children: [ SizedBox(width: 5), AccountImageWidget( user: _usersInfo.getUser(message.userID), ), SizedBox(width: 5), Container( margin: EdgeInsets.only( top: previousMessage == null || message.userID != previousMessage.userID ? 12 : 0), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.65), decoration: BoxDecoration( color: _receiverColor, borderRadius: BorderRadius.only( bottomRight: messageRadius, topRight: messageRadius, bottomLeft: messageRadius, ), ), child: Padding( padding: EdgeInsets.all(10), child: _buildMessage(message), ), ), ]); } Widget _buildMessage(ConversationMessage msg) => ConversationMessageTile( message: msg, user: _usersInfo.getUser(msg.userID), onRequestMessageStats: _requestMessageStats, onRequestMessageUpdate: _updateMessage, onRequestMessageDelete: _deleteMessage, ); Widget _buildDateWidget(DateTime dt) => Center( child: Container( child: Text( formatDisplayDate(dt, time: false), style: TextStyle(fontWeight: FontWeight.bold), ), padding: EdgeInsets.only(top: 50, bottom: 5), )); /// Send new message form Widget _buildSendMessageForm() => Container( padding: EdgeInsets.fromLTRB(10, 5, 10, 5), child: Row( children: [ GestureDetector( onTap: !_isSendingMessage ? _sendFileMessage : null, child: Container( padding: EdgeInsets.all(6), decoration: BoxDecoration( gradient: _isSendingMessage ? _disabledGradient : _fabGradient, shape: BoxShape.circle, ), child: Icon( Icons.add, color: Colors.white, ), ), ), SizedBox(width: 5), Expanded( child: Stack( alignment: Alignment.centerRight, children: [ TextField( enabled: !_isSendingMessage, maxLines: 10, minLines: 1, controller: _textController, focusNode: _textFieldFocus, onTap: () => hideEmojiContainer(), textInputAction: TextInputAction.send, onSubmitted: (s) => _submitTextMessage(), style: TextStyle( color: darkTheme() ? Colors.white : Colors.black, ), onChanged: (s) { _sendWritingEvent(); setState(() {}); }, decoration: InputDecoration( hintText: tr("New message..."), hintStyle: TextStyle( color: _greyColor, ), border: OutlineInputBorder( borderRadius: const BorderRadius.all( const Radius.circular(50.0), ), borderSide: BorderSide.none), contentPadding: EdgeInsets.fromLTRB(20, 8, 32, 8), filled: true, fillColor: _separatorColor, ), ), IconButton( splashColor: Colors.transparent, highlightColor: Colors.transparent, onPressed: () { if (!_showEmojiPicker) { // keyboard is visible hideKeyboard(); Future.delayed(Duration(milliseconds: 100), () => showEmojiContainer()); } else { //keyboard is hidden showKeyboard(); hideEmojiContainer(); } }, icon: Icon( Icons.face, color: _showEmojiPicker ? _senderColor : null, ), ), ], ), ), SizedBox(width: 5), GestureDetector( onTap: _isMessageValid ? _submitTextMessage : null, child: Container( padding: EdgeInsets.all(8), decoration: BoxDecoration( gradient: !_isMessageValid ? _disabledGradient : _fabGradient, shape: BoxShape.circle, ), child: Icon( Icons.send, color: Colors.white, ), ), ), ], ), ); Widget _buildEmojiContainer() => Container( height: 200, child: emoji.EmojiPicker( config: emoji.Config( bgColor: _senderColor, indicatorColor: _senderColor, columns: 8, showRecentsTab: false, ), onEmojiSelected: (category, emoji) { _textController.text = _textController.text + emoji.emoji; }, ), ); Widget _buildSendingWidget() => Container( height: 68, color: _senderColor, child: Row( children: [ Spacer(flex: 1), Flexible( child: LinearProgressIndicator(value: _sendProgress), flex: 5, ), Spacer(flex: 1), Text("${(_sendProgress * 100).toInt()}%"), Spacer(flex: 1), OutlinedButton( onPressed: () => _sendCancel.cancel(), child: Text(tr("Cancel").toUpperCase()), ), Spacer(flex: 1), ], ), ); @override Widget build(BuildContext context) { if (_error == ErrorLevel.MAJOR) return _buildError(); if (_messages == null) return buildCenteredProgressBar(); return Column( children: [ Container( child: _error == ErrorLevel.MINOR ? _buildError() : null, ), Container( child: _loadingOlderMessages == _OlderMessagesLevel.LOADING ? _buildLoadingOlderMessage() : null, ), _messages.length == 0 ? _buildNoMessagesNotice() : _buildMessagesList(), UserWritingInConvNotifier(convID: _conversation.id), _sendCancel != null ? _buildSendingWidget() : _buildSendMessageForm(), _showEmojiPicker ? _buildEmojiContainer() : Container(), ], ); } void _sendWritingEvent() async { try { if (textMessage.isEmpty) return; final t = time(); if (t - _lastWritingEventSent < srvConfig.conversationsPolicy.writingEventInterval) return; _lastWritingEventSent = t; await ConversationsHelper.sendWritingEvent(_conversation.id); } catch (e, s) { logError(e, s); } } /// Request message statistics void _requestMessageStats(ConversationMessage message) async { MainController.of(context) .openConversationMessageStats(_conversation, message); } /// Request message content update Future _updateMessage(ConversationMessage message) async { final newContent = await askUserString( context: context, title: tr("Update message"), message: tr("Please enter new message content:"), defaultValue: message.message.content, hint: tr("New message"), minLength: ServerConfigurationHelper.config.conversationsPolicy.minMessageLen, maxLength: ServerConfigurationHelper.config.conversationsPolicy.maxMessageLen, ); if (newContent == null) return; if (!await _conversationsHelper.updateMessage(message.id, newContent)) { showSimpleSnack(context, tr("Could not update message content!")); return; } } /// Request message deletion Future _deleteMessage(ConversationMessage message) async { final choice = await showDialog( context: context, builder: (c) => AlertDialog( title: Text(tr("Confirm deletion")), content: Text( tr("Do you really want to delete this message ? The operation can not be cancelled !"), textAlign: TextAlign.justify, ), actions: [ TextButton( child: Text( tr("Cancel").toUpperCase(), ), onPressed: () => Navigator.pop(c, false), ), TextButton( child: Text( tr("Confirm").toUpperCase(), style: TextStyle(color: Colors.red), ), onPressed: () => Navigator.pop(c, true), ), ], ), ); if (choice == null || !choice) return; // Execute the request if (!await _conversationsHelper.deleteMessage(message.id)) showSimpleSnack(context, tr("Could not delete conversation message!")); } }