import 'dart:async'; import 'dart:developer'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message_reactions.dart'; import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; import 'package:fluffychat/pangea/enum/activity_display_instructions_enum.dart'; import 'package:fluffychat/pangea/enum/message_mode_enum.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; import 'package:matrix/matrix.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; class MessageSelectionOverlay extends StatefulWidget { final ChatController chatController; final Event _event; final Event? _nextEvent; final Event? _prevEvent; final PangeaMessageEvent? _pangeaMessageEvent; final PangeaToken? _initialSelectedToken; const MessageSelectionOverlay({ required this.chatController, required Event event, required PangeaMessageEvent? pangeaMessageEvent, required PangeaToken? initialSelectedToken, required Event? nextEvent, required Event? prevEvent, super.key, }) : _initialSelectedToken = initialSelectedToken, _pangeaMessageEvent = pangeaMessageEvent, _nextEvent = nextEvent, _prevEvent = prevEvent, _event = event; @override MessageOverlayController createState() => MessageOverlayController(); } class MessageOverlayController extends State with SingleTickerProviderStateMixin { late AnimationController _animationController; StreamSubscription? _reactionSubscription; Animation? _overlayPositionAnimation; MessageMode toolbarMode = MessageMode.translation; PangeaTokenText? _selectedSpan; List? tokens; bool initialized = false; PangeaMessageEvent? get pangeaMessageEvent => widget._pangeaMessageEvent; bool isPlayingAudio = false; bool get showToolbarButtons => pangeaMessageEvent != null && pangeaMessageEvent!.event.messageType == MessageTypes.Text; int get activitiesLeftToComplete => messageAnalyticsEntry?.numActivities ?? 0; bool get isPracticeComplete => activitiesLeftToComplete <= 0; /// Decides whether an _initialSelectedToken should be used /// for a first practice activity on the word meaning PangeaToken? get _selectedTargetTokenForWordMeaning { // if there is no initial selected token, then we don't need to do anything if (widget._initialSelectedToken == null || messageAnalyticsEntry == null) { return null; } debugPrint( "selected token ${widget._initialSelectedToken?.analyticsDebugPrint}", ); debugPrint( "${widget._initialSelectedToken?.vocabConstruct.uses.map((u) => "${u.useType} ${u.timeStamp}").join(", ")}", ); // should not already be involved in a hidden word activity final isInHiddenWordActivity = messageAnalyticsEntry!.isTokenInHiddenWordActivity( widget._initialSelectedToken!, ); // whether the activity should generally be involved in an activity // final shouldDoActivity = widget._initialSelectedToken! // .shouldDoActivity(ActivityTypeEnum.wordMeaning); return !isInHiddenWordActivity ? widget._initialSelectedToken : null; } @override void initState() { super.initState(); _initializeTokensAndMode(); _setupSubscriptions(); } void _setupSubscriptions() { _animationController = AnimationController( vsync: this, duration: const Duration(milliseconds: AppConfig.overlayAnimationDuration), ); _reactionSubscription = widget.chatController.room.client.onSync.stream.where( (update) { // check if this sync update has a reaction event or a // redaction (of a reaction event). If so, rebuild the overlay final room = widget.chatController.room; final timelineEvents = update.rooms?.join?[room.id]?.timeline?.events; if (timelineEvents == null) return false; final eventID = widget._event.eventId; return timelineEvents.any( (e) => e.type == EventTypes.Redaction || (e.type == EventTypes.Reaction && Event.fromMatrixEvent(e, room).relationshipEventId == eventID), ); }, ).listen((_) => setState(() {})); } MessageAnalyticsEntry? get messageAnalyticsEntry => pangeaMessageEvent != null && tokens != null ? MatrixState.pangeaController.getAnalytics.perMessage.get( tokens!, pangeaMessageEvent!, ) : null; Future _initializeTokensAndMode() async { try { final repEvent = pangeaMessageEvent?.messageDisplayRepresentation; if (repEvent != null) { tokens = await repEvent.tokensGlobal( pangeaMessageEvent!.senderId, pangeaMessageEvent!.originServerTs, ); } } catch (e, s) { ErrorHandler.logError( e: e, s: s, data: { "eventID": pangeaMessageEvent?.eventId, }, ); } finally { if (_selectedTargetTokenForWordMeaning != null) { messageAnalyticsEntry?.addForWordMeaning( _selectedTargetTokenForWordMeaning!, ); } _setInitialToolbarMode(); initialized = true; if (mounted) setState(() {}); } } bool get messageInUserL2 => pangeaMessageEvent?.messageDisplayLangCode == MatrixState.pangeaController.languageController.userL2?.langCode; Future _setInitialToolbarMode() async { if (widget._pangeaMessageEvent?.isAudioMessage ?? false) { toolbarMode = MessageMode.speechToText; return setState(() {}); } // 1) we're only going to do activities if we have tokens for the message // 2) if the user selects a span on initialization, then we want to give // them a practice activity on that word // 3) if the user has activities left to complete, then we want to give them if (tokens != null && activitiesLeftToComplete > 0 && messageInUserL2) { return setState(() => toolbarMode = MessageMode.practiceActivity); } // Note: this setting is now hidden so this will always be false // leaving this here in case we want to bring it back if (MatrixState.pangeaController.userController.profile.userSettings .autoPlayMessages) { return setState(() => toolbarMode = MessageMode.textToSpeech); } setState(() => toolbarMode = MessageMode.translation); } /// We need to check if the setState call is safe to call immediately /// Kept getting the error: setState() or markNeedsBuild() called during build. /// This is a workaround to prevent that error @override void setState(VoidCallback fn) { final phase = SchedulerBinding.instance.schedulerPhase; if (mounted && (phase == SchedulerPhase.idle || phase == SchedulerPhase.postFrameCallbacks)) { // It's safe to call setState immediately try { super.setState(fn); } catch (e, s) { ErrorHandler.logError( e: "Error calling setState in MessageSelectionOverlay: $e", s: s, data: {}, ); } } else { // Defer the setState call to after the current frame WidgetsBinding.instance.addPostFrameCallback((_) { try { if (mounted) super.setState(fn); } catch (e, s) { ErrorHandler.logError( e: "Error calling setState in MessageSelectionOverlay after postframeCallback: $e", s: s, data: {}, ); } }); } } /// When an activity is completed, we need to update the state /// and check if the toolbar should be unlocked void onActivityFinish() { if (!mounted) return; _clearSelection(); setState(() {}); } /// In some cases, we need to exit the practice flow and let the user /// interact with the toolbar without completing activities void exitPracticeFlow() { messageAnalyticsEntry?.clearActivityQueue(); _clearSelection(); setState(() {}); } void updateToolbarMode(MessageMode mode) { setState(() { toolbarMode = mode; }); } void _clearSelection() { _selectedSpan = null; setState(() {}); } /// The text that the toolbar should target /// If there is no selectedSpan, then the whole message is the target /// If there is a selectedSpan, then the target is the selected text String get targetText { if (_selectedSpan == null || pangeaMessageEvent == null) { return widget._pangeaMessageEvent?.messageDisplayText ?? widget._event.body; } return widget._pangeaMessageEvent!.messageDisplayText.substring( _selectedSpan!.offset, _selectedSpan!.offset + _selectedSpan!.length, ); } void onClickOverlayMessageToken( PangeaToken token, ) { if ([ MessageMode.practiceActivity, // MessageMode.textToSpeech ].contains(toolbarMode) || isPlayingAudio) { return; } // if there's no selected span, then select the token if (_selectedSpan == null) { _selectedSpan = token.text; } else { // if there is a selected span, then deselect the token if it's the same if (isTokenSelected(token)) { _selectedSpan = null; } else { // if there is a selected span but it is not the same, then select the token _selectedSpan = token.text; } } if (_selectedSpan != null) { widget.chatController.choreographer.tts.tryToSpeak( token.text.content, context, pangeaMessageEvent!.eventId, ); } setState(() {}); } void setSelectedSpan(PracticeActivityModel activity) { if (pangeaMessageEvent == null) return; final RelevantSpanDisplayDetails? span = activity.content.spanDisplayDetails; if (span == null) { debugger(when: kDebugMode); return; } if (span.displayInstructions != ActivityDisplayInstructionsEnum.nothing) { _selectedSpan = PangeaTokenText( offset: span.offset, length: span.length, content: widget._pangeaMessageEvent!.messageDisplayText .substring(span.offset, span.offset + span.length), ); } else { _selectedSpan = null; } setState(() {}); } /// Whether the given token is currently selected bool isTokenSelected(PangeaToken token) { return _selectedSpan?.offset == token.text.offset && _selectedSpan?.length == token.text.length; } /// Whether the overlay is currently displaying a selection bool get isSelection => _selectedSpan != null; PangeaTokenText? get selectedSpan => _selectedSpan; bool get _hasReactions { final reactionsEvents = widget._event.aggregatedEvents( widget.chatController.timeline!, RelationshipTypes.reaction, ); return reactionsEvents.where((e) => !e.redacted).isNotEmpty; } double get _toolbarButtonsHeight => showToolbarButtons ? AppConfig.toolbarButtonsHeight : 0; double get _reactionsHeight => _hasReactions ? 28 : 0; double get _belowMessageHeight => _toolbarButtonsHeight + _reactionsHeight; double get _totalMessageHeight => _messageHeight + _belowMessageHeight; double get _messageHeight => _adjustedMessageHeight != null && _adjustedMessageHeight! < _messageSize!.height ? _adjustedMessageHeight! : _messageSize!.height; void setIsPlayingAudio(bool isPlaying) { if (mounted) { setState(() => isPlayingAudio = isPlaying); } } @override void didChangeDependencies() { super.didChangeDependencies(); if (_messageSize == null || _messageOffset == null || _screenHeight == null) { return; } // position the overlay directly over the underlying message final headerBottomOffset = _screenHeight! - _headerHeight; final footerBottomOffset = _footerHeight; final currentBottomOffset = _screenHeight! - _messageOffset!.dy - _messageHeight - _belowMessageHeight; final bool hasHeaderOverflow = _messageOffset!.dy < (AppConfig.toolbarMaxHeight + _headerHeight); final bool hasFooterOverflow = (_footerHeight + 5) > currentBottomOffset; if (!hasHeaderOverflow && !hasFooterOverflow) return; double scrollOffset = 0; double animationEndOffset = 0; final midpoint = (headerBottomOffset + footerBottomOffset) / 2; // if the overlay would have a footer overflow for this message, // check if shifting the overlay up could cause a header overflow final bottomOffsetDifference = _footerHeight - currentBottomOffset; final newTopOffset = _messageOffset!.dy - bottomOffsetDifference - _belowMessageHeight; final bool upshiftCausesHeaderOverflow = hasFooterOverflow && newTopOffset < (_headerHeight + AppConfig.toolbarMaxHeight); if (hasHeaderOverflow || upshiftCausesHeaderOverflow) { animationEndOffset = midpoint - _totalMessageHeight; final totalTopOffset = midpoint + AppConfig.toolbarMaxHeight; final remainingSpace = _screenHeight! - totalTopOffset; if (remainingSpace < _headerHeight) { // the overlay could run over the header, so it needs to be shifted down animationEndOffset -= (_headerHeight - remainingSpace); } scrollOffset = animationEndOffset - currentBottomOffset; } else if (hasFooterOverflow) { scrollOffset = (_footerHeight + 5) - currentBottomOffset; animationEndOffset = (_footerHeight + 5); } // If, after ajusting the overlay position, the message still overflows the footer, // update the message height to fit the screen. The message is scrollable, so // this will make the both the toolbar box and the toolbar buttons visible. if (animationEndOffset < _footerHeight + _belowMessageHeight) { final double remainingSpace = _screenHeight! - AppConfig.toolbarMaxHeight - _headerHeight - _footerHeight - _belowMessageHeight; if (remainingSpace < _messageHeight) { _adjustedMessageHeight = remainingSpace; } animationEndOffset = _footerHeight; } _overlayPositionAnimation = Tween( begin: currentBottomOffset, end: animationEndOffset, ).animate( CurvedAnimation( parent: _animationController, curve: FluffyThemes.animationCurve, ), ); widget.chatController.scrollController.animateTo( widget.chatController.scrollController.offset - scrollOffset, duration: const Duration(milliseconds: AppConfig.overlayAnimationDuration), curve: FluffyThemes.animationCurve, ); _animationController.forward(); } @override void dispose() { _animationController.dispose(); _reactionSubscription?.cancel(); super.dispose(); } RenderBox? get _messageRenderBox { try { return MatrixState.pAnyState.getRenderBox( widget._event.eventId, ); } catch (e, s) { ErrorHandler.logError( e: "Error getting message render box: $e", s: s, data: { "eventID": widget._event.eventId, }, ); return null; } } Size? get _messageSize { if (_messageRenderBox == null || !_messageRenderBox!.hasSize) { return null; } try { return _messageRenderBox?.size; } catch (e, s) { ErrorHandler.logError( e: "Error getting message size: $e", s: s, data: {}, ); return null; } } Offset? get _messageOffset { if (_messageRenderBox == null || !_messageRenderBox!.hasSize) { return null; } try { return _messageRenderBox?.localToGlobal(Offset.zero); } catch (e) { Sentry.addBreadcrumb(Breadcrumb(message: "Error getting message offset")); return null; } } double? _adjustedMessageHeight; // height of the reply/forward bar + the reaction picker + contextual padding double get _footerHeight { return 56 + 16 + (FluffyThemes.isColumnMode(context) ? 16.0 : 8.0) + (_mediaQuery?.padding.bottom ?? 0); } MediaQueryData? get _mediaQuery { try { return MediaQuery.of(context); } catch (e, s) { ErrorHandler.logError( e: "Error getting media query: $e", s: s, data: {}, ); return null; } } double get _headerHeight { return (Theme.of(context).appBarTheme.toolbarHeight ?? 56) + (_mediaQuery?.padding.top ?? 0); } double? get _screenHeight => _mediaQuery?.size.height; double? get _screenWidth => _mediaQuery?.size.width; @override Widget build(BuildContext context) { if (_messageSize == null) return const SizedBox.shrink(); final bool showDetails = (Matrix.of(context) .store .getBool(SettingKeys.displayChatDetailsColumn) ?? false) && FluffyThemes.isThreeColumnMode(context) && widget.chatController.room.membership == Membership.join; // the default spacing between the side of the screen and the message bubble const double messageMargin = Avatar.defaultSize + 16 + 8; final horizontalPadding = FluffyThemes.isColumnMode(context) ? 8.0 : 0.0; const totalMaxWidth = (FluffyThemes.columnWidth * 2.5) - messageMargin; double? maxWidth; if (_screenWidth != null) { final chatViewWidth = _screenWidth! - (FluffyThemes.isColumnMode(context) ? (FluffyThemes.columnWidth + FluffyThemes.navRailWidth) : 0); maxWidth = chatViewWidth - (2 * horizontalPadding) - messageMargin; } if (maxWidth == null || maxWidth > totalMaxWidth) { maxWidth = totalMaxWidth; } final overlayMessage = Container( constraints: BoxConstraints(maxWidth: maxWidth), child: Material( type: MaterialType.transparency, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: widget._event.senderId == widget._event.room.client.userID ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ if (pangeaMessageEvent != null) MessageToolbar( pangeaMessageEvent: pangeaMessageEvent!, overLayController: this, ), const SizedBox(height: 8), SizedBox( height: _adjustedMessageHeight, child: OverlayMessage( widget._event, pangeaMessageEvent: pangeaMessageEvent, immersionMode: widget.chatController.choreographer.immersionMode, controller: widget.chatController, overlayController: this, nextEvent: widget._nextEvent, prevEvent: widget._prevEvent, timeline: widget.chatController.timeline!, messageWidth: _messageSize!.width, messageHeight: _messageHeight, ), ), if (_hasReactions) Padding( padding: const EdgeInsets.all(4), child: SizedBox( height: _reactionsHeight - 8, child: MessageReactions( widget._event, widget.chatController.timeline!, ), ), ), ToolbarButtons( event: widget._event, overlayController: this, width: 250, ), ], ), ), ); final columnOffset = FluffyThemes.isColumnMode(context) ? FluffyThemes.columnWidth + FluffyThemes.navRailWidth : 0; final double? leftPadding = (widget._event.senderId == widget._event.room.client.userID || _messageOffset == null) ? null : _messageOffset!.dx - horizontalPadding - columnOffset; final double? rightPadding = (widget._event.senderId == widget._event.room.client.userID && _screenWidth != null && _messageOffset != null && _messageSize != null) ? _screenWidth! - _messageOffset!.dx - _messageSize!.width - horizontalPadding : null; final positionedOverlayMessage = (_overlayPositionAnimation == null) ? (_screenHeight == null || _messageSize == null || _messageOffset == null) ? const SizedBox.shrink() : Positioned( left: leftPadding, right: rightPadding, bottom: _screenHeight! - _messageOffset!.dy - _messageHeight - _belowMessageHeight, child: overlayMessage, ) : AnimatedBuilder( animation: _overlayPositionAnimation!, builder: (context, child) { return Positioned( left: leftPadding, right: rightPadding, bottom: _overlayPositionAnimation!.value, child: overlayMessage, ); }, ); return Padding( padding: EdgeInsets.only( left: horizontalPadding, right: horizontalPadding, ), child: Stack( children: [ positionedOverlayMessage, Align( alignment: Alignment.bottomCenter, child: Row( mainAxisSize: MainAxisSize.min, children: [ Expanded( child: Column( mainAxisSize: MainAxisSize.min, children: [ OverlayFooter(controller: widget.chatController), SizedBox(height: _mediaQuery?.padding.bottom ?? 0), ], ), ), if (showDetails) const SizedBox( width: FluffyThemes.columnWidth, ), ], ), ), Material( type: MaterialType.transparency, child: Column( children: [ SizedBox(height: _mediaQuery?.padding.top ?? 0), OverlayHeader(controller: widget.chatController), ], ), ), ], ), ); } }