import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart'; import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/utils/message_text_util.dart'; import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; import 'package:fluffychat/utils/url_launcher.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// Question - does this need to be stateful or does this work? /// Need to test. class MessageTokenText extends StatelessWidget { final PangeaMessageEvent _pangeaMessageEvent; final List? _tokens; final TextStyle _style; final bool Function(PangeaToken)? _isSelected; final void Function(PangeaToken)? _onClick; const MessageTokenText({ super.key, required PangeaMessageEvent pangeaMessageEvent, required List? tokens, required TextStyle style, required void Function(PangeaToken)? onClick, bool Function(PangeaToken)? isSelected, }) : _onClick = onClick, _isSelected = isSelected, _style = style, _tokens = tokens, _pangeaMessageEvent = pangeaMessageEvent; MessageAnalyticsEntry? get messageAnalyticsEntry => _tokens != null ? MatrixState.pangeaController.getAnalytics.perMessage.get( _tokens!, _pangeaMessageEvent, ) : null; @override Widget build(BuildContext context) { if (_tokens == null) { return Text( _pangeaMessageEvent.messageDisplayText, style: _style, ); } void callOnClick(TokenPosition tokenPosition) { _onClick != null && tokenPosition.token != null ? _onClick!(tokenPosition.token!) : null; } return MessageTextWidget( pangeaMessageEvent: _pangeaMessageEvent, style: _style, messageAnalyticsEntry: messageAnalyticsEntry, isSelected: _isSelected, onClick: callOnClick, ); } } class TokenPosition { final int start; final int end; final bool selected; final bool hideContent; final PangeaToken? token; const TokenPosition({ required this.start, required this.end, required this.hideContent, required this.selected, this.token, }); } class HiddenText extends StatelessWidget { final String text; final TextStyle style; const HiddenText({ super.key, required this.text, required this.style, }); @override Widget build(BuildContext context) { final TextPainter textPainter = TextPainter( text: TextSpan(text: text, style: style), textDirection: TextDirection.ltr, )..layout(); final textWidth = textPainter.size.width; final textHeight = textPainter.size.height; return SizedBox( height: textHeight, child: Stack( children: [ Container( width: textWidth, height: textHeight, color: Colors.transparent, ), Positioned( bottom: 0, child: Container( width: textWidth, height: 1, color: style.color, ), ), ], ), ); } } class MessageTextWidget extends StatelessWidget { final PangeaMessageEvent pangeaMessageEvent; final TextStyle style; final MessageAnalyticsEntry? messageAnalyticsEntry; final bool Function(PangeaToken)? isSelected; final void Function(TokenPosition tokenPosition)? onClick; final bool? softWrap; final int? maxLines; final TextOverflow? overflow; const MessageTextWidget({ super.key, required this.pangeaMessageEvent, required this.style, this.messageAnalyticsEntry, this.isSelected, this.onClick, this.softWrap, this.maxLines, this.overflow, }); @override Widget build(BuildContext context) { final Characters messageCharacters = pangeaMessageEvent.messageDisplayText.characters; final tokenPositions = MessageTextUtil.getTokenPositions( pangeaMessageEvent, messageAnalyticsEntry: messageAnalyticsEntry, isSelected: isSelected, ); if (tokenPositions == null) { return Text( pangeaMessageEvent.messageDisplayText, style: style, softWrap: softWrap, maxLines: maxLines, overflow: overflow, ); } final hasHiddenTokens = tokenPositions.any((t) => t.hideContent); return RichText( softWrap: softWrap ?? true, maxLines: maxLines, overflow: overflow ?? TextOverflow.clip, text: TextSpan( children: tokenPositions.mapIndexed((int i, TokenPosition tokenPosition) { final shouldDo = tokenPosition.token?.shouldDoActivity( a: ActivityTypeEnum.wordMeaning, feature: null, tag: null, ) ?? false; final didMeaningActivity = tokenPosition.token?.didActivitySuccessfully( ActivityTypeEnum.wordMeaning, ) ?? true; final substring = messageCharacters .skip(tokenPosition.start) .take(tokenPosition.end - tokenPosition.start) .toString(); Color backgroundColor = Colors.transparent; if (!hasHiddenTokens) { if (tokenPosition.selected) { backgroundColor = AppConfig.primaryColor.withAlpha(80); } else if (isSelected != null && shouldDo) { backgroundColor = !didMeaningActivity ? AppConfig.success.withAlpha(60) : AppConfig.gold.withAlpha(60); } } if (tokenPosition.token != null) { if (tokenPosition.hideContent) { return WidgetSpan( child: GestureDetector( onTap: onClick != null ? () => onClick?.call(tokenPosition) : null, child: HiddenText(text: substring, style: style), ), ); } return LinkifySpan( mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = onClick != null ? () => onClick?.call(tokenPosition) : null, text: substring, style: style.merge( TextStyle( backgroundColor: backgroundColor, ), ), linkStyle: const TextStyle( decoration: TextDecoration.underline, ), onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ); } else { if ((i > 0 || i < tokenPositions.length - 1) && tokenPositions[i + 1].hideContent && tokenPositions[i - 1].hideContent) { return WidgetSpan( child: GestureDetector( onTap: onClick != null ? () => onClick?.call(tokenPosition) : null, child: HiddenText(text: substring, style: style), ), ); } return LinkifySpan( text: substring, style: style, options: const LinkifyOptions(humanize: false), linkStyle: const TextStyle( decoration: TextDecoration.underline, ), onOpen: (url) => UrlLauncher(context, url.url).launchUrl(), ); } }).toList(), ), ); } }