import 'dart:async'; import 'dart:collection'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:pie_menu/pie_menu.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/model/json/message_old.dart'; import 'package:twonly/src/model/memory_item.model.dart'; import 'package:twonly/src/model/protobuf/push_notification/push_notification.pb.dart'; import 'package:twonly/src/services/api/messages.dart'; import 'package:twonly/src/services/notifications/background.notifications.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_date_chip.dart'; import 'package:twonly/src/views/chats/chat_messages_components/chat_list_entry.dart'; import 'package:twonly/src/views/chats/chat_messages_components/response_container.dart'; import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/initialsavatar.dart'; import 'package:twonly/src/views/components/user_context_menu.dart'; import 'package:twonly/src/views/components/verified_shield.dart'; import 'package:twonly/src/views/contact/contact.view.dart'; import 'package:twonly/src/views/tutorial/tutorials.dart'; Color getMessageColor(Message message) { return (message.messageOtherId == null) ? const Color.fromARGB(255, 58, 136, 102) : const Color.fromARGB(233, 68, 137, 255); } class ChatMessage { ChatMessage({required this.message, required this.responseTo}); final Message message; final Message? responseTo; } class ChatItem { const ChatItem._({this.message, this.date, this.time}); factory ChatItem.date(DateTime date) { return ChatItem._(date: date); } factory ChatItem.time(DateTime time) { return ChatItem._(time: time); } factory ChatItem.message(ChatMessage message) { return ChatItem._(message: message); } final ChatMessage? message; final DateTime? date; final DateTime? time; bool get isMessage => message != null; bool get isDate => date != null; bool get isTime => time != null; } /// Displays detailed information about a SampleItem. class ChatMessagesView extends StatefulWidget { const ChatMessagesView(this.contact, {super.key}); final Contact contact; @override State createState() => _ChatMessagesViewState(); } class _ChatMessagesViewState extends State { TextEditingController newMessageController = TextEditingController(); HashSet alreadyReportedOpened = HashSet(); late Contact user; String currentInputText = ''; late StreamSubscription userSub; late StreamSubscription> messageSub; List messages = []; List galleryItems = []; Map> emojiReactionsToMessageId = {}; Message? responseToMessage; GlobalKey verifyShieldKey = GlobalKey(); late FocusNode textFieldFocus; Timer? tutorial; final ItemScrollController itemScrollController = ItemScrollController(); int? focusedScrollItem; @override void initState() { super.initState(); user = widget.contact; textFieldFocus = FocusNode(); initStreams(); tutorial = Timer(const Duration(seconds: 1), () async { tutorial = null; if (!mounted) return; await showVerifyShieldTutorial(context, verifyShieldKey); }); } @override void dispose() { userSub.cancel(); messageSub.cancel(); tutorial?.cancel(); textFieldFocus.dispose(); super.dispose(); } Future initStreams() async { await twonlyDB.messagesDao.removeOldMessages(); final contact = twonlyDB.contactsDao.watchContact(widget.contact.userId); userSub = contact.listen((contact) { if (contact == null) return; setState(() { user = contact; }); }); final msgStream = twonlyDB.messagesDao.watchAllMessagesFrom(widget.contact.userId); messageSub = msgStream.listen((newMessages) async { // if (!context.mounted) return; if (Platform.isAndroid) { await flutterLocalNotificationsPlugin.cancel(widget.contact.userId); } else { await flutterLocalNotificationsPlugin.cancelAll(); } final chatItems = []; final storedMediaFiles = []; DateTime? lastDate; final tmpEmojiReactionsToMessageId = >{}; // only send openedMessage to one text message, as receiver will then set all as read... List openedTextMessageOtherIds; final messageOtherMessageIdToMyMessageId = {}; final messageIdToMessage = {}; /// there is probably a better way... for (final msg in newMessages) { if (msg.messageOtherId != null) { messageOtherMessageIdToMyMessageId[msg.messageOtherId!] = msg.messageId; } messageIdToMessage[msg.messageId] = msg; } for (final msg in newMessages) { if (msg.kind == MessageKind.textMessage && msg.messageOtherId != null && msg.openedAt == null && (openedTextMessageOtherIds == null || openedTextMessageOtherIds < msg.messageOtherId!)) { openedTextMessageOtherIds.add(msg.messageOtherId); } Message? responseTo; if (msg.kind == MessageKind.media && msg.mediaStored) { storedMediaFiles.add(msg); } final responseId = msg.responseToMessageId ?? messageOtherMessageIdToMyMessageId[msg.responseToOtherMessageId]; var isReaction = false; if (responseId != null) { responseTo = messageIdToMessage[responseId]; final content = MessageContent.fromJson( msg.kind, jsonDecode(msg.contentJson!) as Map, ); if (content is TextMessageContent) { if (isEmoji(content.text)) { isReaction = true; tmpEmojiReactionsToMessageId .putIfAbsent(responseId, () => []) .add(msg); } } if (msg.kind == MessageKind.reopenedMedia) { isReaction = true; tmpEmojiReactionsToMessageId .putIfAbsent(responseId, () => []) .add(msg); } } if (!isReaction) { if (lastDate == null || msg.sendAt.day != lastDate.day || msg.sendAt.month != lastDate.month || msg.sendAt.year != lastDate.year) { chatItems.add(ChatItem.date(msg.sendAt)); lastDate = msg.sendAt; } else if (msg.sendAt.difference(lastDate).inMinutes >= 20) { chatItems.add(ChatItem.time(msg.sendAt)); lastDate = msg.sendAt; } chatItems.add( ChatItem.message( ChatMessage( message: msg, responseTo: responseTo, ), ), ); } } if (openedTextMessageOtherIds.isNotEmpty) { await notifyContactAboutOpeningMessage( widget.contact.userId, openedTextMessageOtherIds, ); } await twonlyDB.messagesDao .openedAllNonMediaMessages(widget.contact.userId); setState(() { emojiReactionsToMessageId = tmpEmojiReactionsToMessageId; messages = chatItems.reversed.toList(); }); final items = await MemoryItem.convertFromMessages(storedMediaFiles); galleryItems = items.values.toList(); setState(() {}); }); } Future _sendMessage() async { if (newMessageController.text == '') return; await sendTextMessage( user.userId, TextMessageContent( text: newMessageController.text, responseToMessageId: responseToMessage?.messageOtherId, responseToOtherMessageId: responseToMessage?.messageId, ), PushNotification( kind: (responseToMessage == null) ? PushKind.text : (isEmoji(newMessageController.text)) ? PushKind.reaction : PushKind.response, reactionContent: (isEmoji(newMessageController.text)) ? newMessageController.text : null, ), ); newMessageController.clear(); currentInputText = ''; responseToMessage = null; setState(() {}); } Future scrollToMessage(int messageId) async { final index = messages.indexWhere( (x) => x.isMessage && x.message!.message.messageId == messageId, ); if (index == -1) return; setState(() { focusedScrollItem = index; }); await itemScrollController.scrollTo( index: index, duration: const Duration(milliseconds: 300), alignment: 0.5, ); Future.delayed(const Duration(milliseconds: 300), () { if (!context.mounted) return; setState(() { focusedScrollItem = null; }); }); } @override Widget build(BuildContext context) { return GestureDetector( onTap: () => FocusScope.of(context).unfocus(), child: Scaffold( appBar: AppBar( title: GestureDetector( onTap: () { Navigator.push( context, MaterialPageRoute( builder: (context) { return ContactView(widget.contact.userId); }, ), ); }, child: Row( children: [ ContactAvatar( contact: user, fontSize: 19, ), const SizedBox(width: 10), Expanded( child: ColoredBox( color: Colors.transparent, child: Row( children: [ Text(getContactDisplayName(user)), const SizedBox(width: 10), if (user.verified) VerifiedShield(key: verifyShieldKey, user), ], ), ), ), ], ), ), ), body: PieCanvas( theme: getPieCanvasTheme(context), child: SafeArea( child: Column( children: [ Expanded( child: ScrollablePositionedList.builder( reverse: true, itemCount: messages.length + 1, itemScrollController: itemScrollController, itemBuilder: (context, i) { if (i == messages.length) { return const Padding( padding: EdgeInsetsGeometry.only(top: 10), ); } if (messages[i].isDate || messages[i].isTime) { return ChatDateChip( item: messages[i], ); } else { final chatMessage = messages[i].message!; return Transform.translate( offset: Offset( (focusedScrollItem == i) ? (chatMessage.message.messageOtherId == null) ? -8 : 8 : 0, 0, ), child: Transform.scale( scale: (focusedScrollItem == i) ? 1.05 : 1, child: ChatListEntry( key: Key(chatMessage.message.messageId.toString()), chatMessage, user, galleryItems, isLastMessageFromSameUser(messages, i), emojiReactionsToMessageId[ chatMessage.message.messageId] ?? [], scrollToMessage: scrollToMessage, onResponseTriggered: () { setState(() { responseToMessage = chatMessage.message; }); textFieldFocus.requestFocus(); }, ), ), ); } }, ), ), if (responseToMessage != null && !user.deleted) Container( padding: const EdgeInsets.only( left: 20, right: 20, top: 10, ), child: Row( children: [ Expanded( child: ResponsePreview( message: responseToMessage!, showBorder: true, contact: user, ), ), IconButton( onPressed: () { setState(() { responseToMessage = null; }); }, icon: const FaIcon( FontAwesomeIcons.xmark, size: 16, ), ), ], ), ), Padding( padding: const EdgeInsets.only( bottom: 30, left: 20, right: 20, top: 10, ), child: Row( children: (user.deleted) ? [] : [ Expanded( child: TextField( controller: newMessageController, focusNode: textFieldFocus, keyboardType: TextInputType.multiline, maxLines: 4, minLines: 1, onChanged: (value) { currentInputText = value; setState(() {}); }, onSubmitted: (_) { _sendMessage(); }, decoration: inputTextMessageDeco(context), ), ), if (currentInputText != '') IconButton( padding: const EdgeInsets.all(15), icon: const FaIcon( FontAwesomeIcons.solidPaperPlane, ), onPressed: _sendMessage, ) else IconButton( icon: const FaIcon(FontAwesomeIcons.camera), padding: const EdgeInsets.all(15), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) { return CameraSendToView(widget.contact); }, ), ); }, ), ], ), ), ], ), ), ), ), ); } } bool isLastMessageFromSameUser(List messages, int index) { if (index <= 0) { return true; // If there is no previous message, return true } final lastMessage = messages[index - 1]; final currentMessage = messages[index]; if (lastMessage.isMessage && currentMessage.isMessage) { // Check if both messages have the same messageOtherId (or both are null) return (lastMessage.message!.message.messageOtherId == null && currentMessage.message!.message.messageOtherId == null) || (lastMessage.message!.message.messageOtherId != null && currentMessage.message!.message.messageOtherId != null); } return false; } double calculateNumberOfLines(String text, double width, double fontSize) { final textPainter = TextPainter( text: TextSpan( text: text, style: TextStyle(fontSize: fontSize), ), textDirection: TextDirection.ltr, )..layout(maxWidth: width - 32); return textPainter.computeLineMetrics().length.toDouble(); }