import 'dart:async'; import 'dart:convert'; import 'dart:math'; import 'package:async_tools/async_tools.dart'; import 'package:awesome_extensions/awesome_extensions.dart'; import 'package:fast_immutable_collections/fast_immutable_collections.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_chat_core/flutter_chat_core.dart' as core; import 'package:flutter_chat_ui/flutter_chat_ui.dart'; import 'package:flutter_translate/flutter_translate.dart'; import 'package:veilid_support/veilid_support.dart'; import '../../account_manager/account_manager.dart'; import '../../contacts/contacts.dart'; import '../../conversation/conversation.dart'; import '../../notifications/notifications.dart'; import '../../theme/theme.dart'; import '../chat.dart'; import 'chat_builders/chat_builders.dart'; const onEndReachedThreshold = 0.75; const _kScrollTag = 'kScrollTag'; const kSeqId = 'seqId'; const kSending = 'sending'; const maxMessageLength = 2048; class ChatComponentWidget extends StatefulWidget { const ChatComponentWidget._({ required super.key, required TypedKey localConversationRecordKey, required void Function() onCancel, required void Function() onClose, }) : _localConversationRecordKey = localConversationRecordKey, _onCancel = onCancel, _onClose = onClose; // Create a single-contact chat and its associated state static Widget singleContact({ required BuildContext context, required TypedKey localConversationRecordKey, required void Function() onCancel, required void Function() onClose, Key? key, }) { // Get the account info final accountInfo = context.watch().state; // Get the account record cubit final accountRecordCubit = context.read(); // Get the contact list cubit final contactListCubit = context.watch(); // Get the active conversation cubit final activeConversationCubit = context.select< ActiveConversationsBlocMapCubit, ActiveConversationCubit?>((x) => x.entry(localConversationRecordKey)); if (activeConversationCubit == null) { return waitingPage(onCancel: onCancel); } // Get the messages cubit final messagesCubit = context.select( (x) => x.entry(localConversationRecordKey)); if (messagesCubit == null) { return waitingPage(onCancel: onCancel); } // Make chat component state return BlocProvider( key: key, create: (context) => ChatComponentCubit.singleContact( accountInfo: accountInfo, accountRecordCubit: accountRecordCubit, contactListCubit: contactListCubit, activeConversationCubit: activeConversationCubit, messagesCubit: messagesCubit, ), child: ChatComponentWidget._( key: ValueKey(localConversationRecordKey), localConversationRecordKey: localConversationRecordKey, onCancel: onCancel, onClose: onClose)); } @override State createState() => _ChatComponentWidgetState(); //////////////////////////////////////////////////////////////////////////// final TypedKey _localConversationRecordKey; final void Function() _onCancel; final void Function() _onClose; } class _ChatComponentWidgetState extends State { //////////////////////////////////////////////////////////////////// @override void initState() { _chatController = core.InMemoryChatController(); _textEditingController = TextEditingController(); _scrollController = ScrollController(); _chatStateProcessor = SingleStateProcessor(); _focusNode = FocusNode(); final chatComponentCubit = context.read(); _chatStateProcessor.follow( chatComponentCubit.stream, chatComponentCubit.state, _updateChatState); super.initState(); } @override void dispose() { unawaited(_chatStateProcessor.close()); _focusNode.dispose(); _chatController.dispose(); _scrollController.dispose(); _textEditingController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final theme = Theme.of(context); final scaleTheme = theme.extension()!; final scale = scaleTheme.scheme.scale(ScaleKind.primary); final textTheme = theme.textTheme; final scaleChatTheme = scaleTheme.chatTheme(); // Get the enclosing chat component cubit that contains our state // (created by ChatComponentWidget.singleContact()) final chatComponentCubit = context.watch(); final chatComponentState = chatComponentCubit.state; final localUser = chatComponentState.localUser; if (localUser == null) { return const EmptyChatWidget(); } final messageWindow = chatComponentState.messageWindow.asData?.value; if (messageWindow == null) { return chatComponentState.messageWindow.buildNotData(); } final isLastPage = messageWindow.windowStart == 0; final isFirstPage = messageWindow.windowEnd == messageWindow.length - 1; final title = chatComponentState.title; if (chatComponentCubit.scrollOffset != 0) { _scrollController.position.correctPixels( _scrollController.position.pixels + chatComponentCubit.scrollOffset); chatComponentCubit.scrollOffset = 0; } return Column( children: [ Container( height: 48, decoration: BoxDecoration( color: scale.border, ), child: Row(children: [ Align( alignment: AlignmentDirectional.centerStart, child: Padding( padding: const EdgeInsetsDirectional.fromSTEB(16, 0, 16, 0), child: Text(title, textAlign: TextAlign.start, style: textTheme.titleMedium! .copyWith(color: scale.borderText)), )), const Spacer(), IconButton( iconSize: 24, icon: Icon(Icons.close, color: scale.borderText), onPressed: widget._onClose) .paddingLTRB(0, 0, 8, 0) ]), ), DecoratedBox( decoration: const BoxDecoration(color: Colors.transparent), child: NotificationListener( onNotification: (notification) { if (chatComponentCubit.scrollOffset != 0) { return false; } if (!isFirstPage && notification.metrics.pixels <= ((notification.metrics.maxScrollExtent - notification.metrics.minScrollExtent) * (1.0 - onEndReachedThreshold) + notification.metrics.minScrollExtent)) { // final scrollOffset = (notification.metrics.maxScrollExtent - notification.metrics.minScrollExtent) * (1.0 - onEndReachedThreshold); chatComponentCubit.scrollOffset = scrollOffset; // singleFuture((chatComponentCubit, _kScrollTag), () async { await _handlePageForward( chatComponentCubit, messageWindow, notification); }); } else if (!isLastPage && notification.metrics.pixels >= ((notification.metrics.maxScrollExtent - notification.metrics.minScrollExtent) * onEndReachedThreshold + notification.metrics.minScrollExtent)) { // final scrollOffset = -(notification.metrics.maxScrollExtent - notification.metrics.minScrollExtent) * (1.0 - onEndReachedThreshold); chatComponentCubit.scrollOffset = scrollOffset; // singleFuture((chatComponentCubit, _kScrollTag), () async { await _handlePageBackward( chatComponentCubit, messageWindow, notification); }); } return false; }, child: ValueListenableBuilder( valueListenable: _textEditingController, builder: (context, textEditingValue, __) { final messageIsValid = _messageIsValid(textEditingValue.text); var sendIconColor = scaleTheme.config.preferBorders ? scale.border : scale.borderText; if (!messageIsValid || _textEditingController.text.isEmpty) { sendIconColor = sendIconColor.withAlpha(128); } return Chat( currentUserId: localUser.id, resolveUser: (id) async { if (id == localUser.id) { return localUser; } return chatComponentState.remoteUsers.get(id); }, chatController: _chatController, onMessageSend: (text) => _handleSendPressed(chatComponentCubit, text), theme: scaleChatTheme.chatTheme, builders: core.Builders( // Chat list builder chatAnimatedListBuilder: (context, itemBuilder) => ChatAnimatedListReversed( scrollController: _scrollController, itemBuilder: itemBuilder), // Text message builder textMessageBuilder: (context, message, index) => VcTextMessageWidget( message: message, index: index, padding: const EdgeInsets.symmetric( vertical: 12, horizontal: 16) .scaled(context) // showTime: true, // showStatus: true, ), // Composer builder composerBuilder: (ctx) => VcComposerWidget( autofocus: true, padding: const EdgeInsets.all(4).scaled(context), gap: 8.scaled(context), focusNode: _focusNode, textInputAction: isAnyMobile ? TextInputAction.newline : TextInputAction.send, shiftEnterAction: isAnyMobile ? ShiftEnterAction.send : ShiftEnterAction.newline, textEditingController: _textEditingController, maxLength: maxMessageLength, keyboardType: TextInputType.multiline, sendIconColor: sendIconColor, topWidget: messageIsValid ? null : Text(translate('chat.message_too_long'), style: TextStyle( color: scaleTheme .scheme.errorScale.primary)) .toCenter(), ), ), timeFormat: core.DateFormat.jm(), ); }))).expanded(), ], ); } ///////////////////////////////////////////////////////////////////// bool _messageIsValid(String text) => utf8.encode(text).lengthInBytes < maxMessageLength; Future _updateChatState(ChatComponentState chatComponentState) async { // Update message window state final data = chatComponentState.messageWindow.asData; if (data == null) { await _chatController.setMessages([]); return; } final windowState = data.value; // await _chatController.setMessages(windowState.window.toList()); final newMessagesSet = windowState.window.toSet(); final newMessagesById = Map.fromEntries(newMessagesSet.map((m) => MapEntry(m.id, m))); final newMessagesBySeqId = Map.fromEntries( newMessagesSet.map((m) => MapEntry(m.metadata![kSeqId], m))); final oldMessagesSet = _chatController.messages.toSet(); if (oldMessagesSet.isEmpty) { await _chatController.setMessages(windowState.window.toList()); return; } // See how many messages differ by equality (not identity) // If there are more than `replaceAllMessagesThreshold` differences // just replace the whole list of messages final diffs = newMessagesSet.diffAndIntersect(oldMessagesSet, diffThisMinusOther: true, diffOtherMinusThis: true); final addedMessages = diffs.diffThisMinusOther!; final removedMessages = diffs.diffOtherMinusThis!; final replaceAllPaginationLimit = windowState.windowCount / 3; if ((addedMessages.length >= replaceAllPaginationLimit) || removedMessages.length >= replaceAllPaginationLimit) { await _chatController.setMessages(windowState.window.toList()); return; } // Remove messages that are gone, and replace the ones that have changed for (final m in removedMessages) { final newm = newMessagesById[m.id]; if (newm != null) { await _chatController.updateMessage(m, newm); } else { final newm = newMessagesBySeqId[m.metadata![kSeqId]]; if (newm != null) { await _chatController.updateMessage(m, newm); addedMessages.remove(newm); } else { await _chatController.removeMessage(m); } } } if (addedMessages.isNotEmpty) { await _chatController.setMessages(windowState.window.toList()); } // // // Check for append // if (addedMessages.isNotEmpty) { // if (_chatController.messages.isNotEmpty && // (addedMessages.first.metadata![kSeqId] as int) > // (_chatController.messages.reversed.last.metadata![kSeqId] // as int)) { // await _chatController.insertAllMessages(addedMessages.reversedView, // index: 0); // } // // Check for prepend // else if (_chatController.messages.isNotEmpty && // (addedMessages.last.metadata![kSeqId] as int) < // (_chatController.messages.reversed.first.metadata![kSeqId] // as int)) { // await _chatController.insertAllMessages( // addedMessages.reversedView, // ); // } // // Otherwise just replace // // xxx could use a better algorithm here to merge added messages in // else { // await _chatController.setMessages(windowState.window.toList()); // } // } } void _handleSendPressed(ChatComponentCubit chatComponentCubit, String text) { _focusNode.requestFocus(); if (text.startsWith('/')) { chatComponentCubit.runCommand(text); return; } if (!_messageIsValid(text)) { context .read() .error(text: translate('chat.message_too_long')); return; } chatComponentCubit.sendMessage(text: text); } // void _handleAttachmentPressed() async { Future _handlePageForward( ChatComponentCubit chatComponentCubit, WindowState messageWindow, ScrollNotification notification) async { debugPrint( '_handlePageForward: messagesState.length=${messageWindow.length} ' 'messagesState.windowTail=${messageWindow.windowTail} ' 'messagesState.windowCount=${messageWindow.windowCount} ' 'ScrollNotification=$notification'); // Go forward a page final tail = min(messageWindow.length, messageWindow.windowTail + (messageWindow.windowCount ~/ 4)) % messageWindow.length; // Set follow final follow = messageWindow.length == 0 || tail == 0; // xxx incorporate scroll position // final scrollOffset = (notification.metrics.maxScrollExtent - // notification.metrics.minScrollExtent) * // (1.0 - onEndReachedThreshold); // chatComponentCubit.scrollOffset = scrollOffset; await chatComponentCubit.setWindow( tail: tail, count: messageWindow.windowCount, follow: follow); // chatComponentCubit.state.scrollController.position.jumpTo( // chatComponentCubit.state.scrollController.offset + scrollOffset); //chatComponentCubit.scrollOffset = 0; } Future _handlePageBackward( ChatComponentCubit chatComponentCubit, WindowState messageWindow, ScrollNotification notification, ) async { debugPrint( '_handlePageBackward: messagesState.length=${messageWindow.length} ' 'messagesState.windowTail=${messageWindow.windowTail} ' 'messagesState.windowCount=${messageWindow.windowCount} ' 'ScrollNotification=$notification'); // Go back a page final tail = max( messageWindow.windowCount, (messageWindow.windowTail - (messageWindow.windowCount ~/ 4)) % messageWindow.length); // Set follow final follow = messageWindow.length == 0 || tail == 0; // xxx incorporate scroll position // final scrollOffset = -(notification.metrics.maxScrollExtent - // notification.metrics.minScrollExtent) * // (1.0 - onEndReachedThreshold); // chatComponentCubit.scrollOffset = scrollOffset; await chatComponentCubit.setWindow( tail: tail, count: messageWindow.windowCount, follow: follow); // chatComponentCubit.scrollOffset = scrollOffset; // chatComponentCubit.state.scrollController.position.jumpTo( // chatComponentCubit.state.scrollController.offset + scrollOffset); //chatComponentCubit.scrollOffset = 0; } late final core.ChatController _chatController; late final TextEditingController _textEditingController; late final ScrollController _scrollController; late final SingleStateProcessor _chatStateProcessor; late final FocusNode _focusNode; }