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/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/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:flutter/material.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; bool _isSendingMessage = false; TextEditingController _textEditingController = TextEditingController(); ScrollWatcher _scrollController; _OlderMessagesLevel _loadingOlderMessages = _OlderMessagesLevel.NONE; String get textMessage => _textEditingController.text; bool get _isMessageValid => textMessage.length >= ServerConfigurationHelper.config.conversationsPolicy.minMessageLen && textMessage.length < ServerConfigurationHelper.config.conversationsPolicy.maxMessageLen; @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)); } }); } /// 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, ); if (file == null) return; await _submitMessage( NewConversationMessage( conversationID: widget.conversationID, message: null, file: file, ), ); } catch (e, s) { logError(e, s); showSimpleSnack(context, tr("Failed to send a file!")); } } /// 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) Scaffold.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(() => _textEditingController = TextEditingController()); } /// Check if a message is the last message of a user or not bool _isLastMessage(int index) { return index == 0 || (index > 0 && _messages[index - 1].userID != _messages[index].userID); } /// Check if a message is the first message of a user or not bool _isFirstMessage(int index) { return index == _messages.length - 1 || (index < _messages.length - 1 && _messages[index + 1].userID != _messages[index].userID); } /// 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 converation.")), ), ); } /// Messages list Widget _buildMessagesList() { return Expanded( child: ListView.builder( controller: _scrollController, reverse: true, itemCount: _messages.length, itemBuilder: (c, i) { return _messages[i].isServerMessage ? ServerConversationMessageTile( message: _messages[i].serverMessage, users: _usersInfo, ) : ConversationMessageTile( conversation: _conversation, message: _messages.elementAt(i), userInfo: _usersInfo.getUser(_messages[i].userID), isLastMessage: _isLastMessage(i), isFirstMessage: _isFirstMessage(i), onRequestMessageStats: _requestMessageStats, onRequestMessageUpdate: _updateMessage, onRequestMessageDelete: _deleteMessage, ); }), ); } /// Send message form Widget _buildSendMessageForm() { return new Container( margin: const EdgeInsets.symmetric(horizontal: 8.0), child: new Row( children: [ // Image area new Container( margin: new EdgeInsets.symmetric(horizontal: 4.0), child: new IconButton( icon: new Icon( Icons.add, color: _isSendingMessage ? Theme.of(context).disabledColor : Theme.of(context).accentColor, ), onPressed: () => _sendFileMessage(), ), ), // Message area new Flexible( child: new TextField( keyboardType: TextInputType.text, maxLines: null, maxLength: ServerConfigurationHelper .config.conversationsPolicy.maxMessageLen, maxLengthEnforced: true, // Show max length only when there is some text already typed buildCounter: smartInputCounterWidgetBuilder, enabled: !_isSendingMessage, controller: _textEditingController, onChanged: (s) => setState(() {}), onSubmitted: _isMessageValid ? (s) => _submitTextMessage() : null, decoration: new InputDecoration.collapsed( hintText: tr("Send a message"), ), ), ), // Send button new Container( margin: const EdgeInsets.symmetric(horizontal: 4.0), child: new IconButton( icon: new Icon( Icons.send, color: !_isSendingMessage && _isMessageValid ? Theme.of(context).accentColor : Theme.of(context).disabledColor, ), onPressed: !_isSendingMessage && _isMessageValid ? () => _submitTextMessage() : null, ), ), ], ), ); } @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(), Divider(), _buildSendMessageForm() ], ); } /// 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: [ FlatButton( child: Text( tr("Cancel").toUpperCase(), ), onPressed: () => Navigator.pop(c, false), ), FlatButton( 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!")); } }