import 'dart:developer'; import 'dart:math'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/common/utils/error_handler.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/message_token_text/dotted_border_painter.dart'; import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; import 'package:fluffychat/pangea/morphs/morph_features_enum.dart'; import 'package:fluffychat/pangea/morphs/morph_icon.dart'; import 'package:fluffychat/pangea/practice_activities/practice_choice.dart'; import 'package:fluffychat/pangea/practice_activities/practice_target.dart'; import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart'; import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart'; import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart'; const double tokenButtonHeight = 40.0; const double tokenButtonDefaultFontSize = 10; const int maxEmojisPerLemma = 1; const double estimatedEmojiWidthRatio = 2; class MessageTokenButton extends StatefulWidget { final MessageOverlayController? overlayController; final PangeaToken token; final TextStyle textStyle; final double width; final bool animateIn; const MessageTokenButton({ super.key, required this.overlayController, required this.token, required this.textStyle, required this.width, this.animateIn = false, }); @override MessageTokenButtonState createState() => MessageTokenButtonState(); } class MessageTokenButtonState extends State with TickerProviderStateMixin { AnimationController? _controller; Animation? _heightAnimation; // New controller and animation for icon size AnimationController? _iconSizeController; Animation? _iconSizeAnimation; bool _isHovered = false; bool _isSelected = false; bool _finishedInitialAnimation = false; bool _wasEmpty = false; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: const Duration( milliseconds: AppConfig.overlayAnimationDuration, ), ); _heightAnimation = Tween( begin: 0, end: tokenButtonHeight, ).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeOut)); // Initialize the new icon size controller and animation _iconSizeController = AnimationController( vsync: this, duration: const Duration(milliseconds: 200), ); _iconSizeAnimation = Tween( begin: 24, // Default icon size end: 30, // Enlarged icon size ).animate( CurvedAnimation(parent: _iconSizeController!, curve: Curves.easeInOut), ); _setSelected(); // Call _setSelected after initializing _iconSizeController _wasEmpty = _isEmpty; if (!_isEmpty) { _controller?.forward().then((_) { if (mounted) setState(() => _finishedInitialAnimation = true); }); } else { setState(() => _finishedInitialAnimation = true); } } @override void didUpdateWidget(covariant MessageTokenButton oldWidget) { super.didUpdateWidget(oldWidget); _setSelected(); if (_isEmpty != _wasEmpty) { if (_isEmpty && _animate) { _controller?.reverse(); } else if (!_isEmpty && _animate) { _controller?.forward(); } setState(() => _wasEmpty = _isEmpty); } } @override void dispose() { _controller?.dispose(); _iconSizeController?.dispose(); // Dispose the new controller super.dispose(); } PracticeTarget? get _activity => widget.overlayController?.practiceTargetForToken(widget.token); bool get _animate => widget.animateIn || _finishedInitialAnimation; bool get _isActivityCompleteOrNullForToken => _activity?.isCompleteByToken( widget.token, _activity!.morphFeature, ) == true; void _setSelected() { final selected = widget.overlayController?.selectedMorph?.token == widget.token && widget.overlayController?.selectedMorph?.morph == _activity?.morphFeature; if (selected != _isSelected) { setState(() { _isSelected = selected; }); _isSelected ? _iconSizeController?.forward() : _iconSizeController?.reverse(); } } void _setHovered(bool isHovered) { if (isHovered != _isHovered) { setState(() { _isHovered = isHovered; }); if (!_isHovered && _isSelected) { return; } _isHovered ? _iconSizeController?.forward() : _iconSizeController?.reverse(); } } void _onMatch(PracticeChoice form) { if (widget.overlayController?.activity == null) { debugger(when: kDebugMode); ErrorHandler.logError( m: "should not be in onAcceptWithDetails with null activity", data: {"details": form}, ); return; } widget.overlayController!.onChoiceSelect(null); widget.overlayController!.activity!.onMatch( widget.token, form, widget.overlayController!.pangeaMessageEvent, () => widget.overlayController!.setState(() {}), ); } bool get _isEmpty { final mode = widget.overlayController?.toolbarMode; if (MessageMode.wordEmoji == mode && widget.token.vocabConstructID.userSetEmoji.firstOrNull != null) { return false; } return _activity == null || (_isActivityCompleteOrNullForToken && ![MessageMode.wordEmoji, MessageMode.wordMorph].contains(mode)) || (MessageMode.wordMorph == mode && _activity?.morphFeature == null); } @override Widget build(BuildContext context) { if (widget.overlayController == null) { return const SizedBox.shrink(); } if (!_animate && _iconSizeAnimation != null) { return MessageTokenButtonContent( activity: _activity, messageMode: widget.overlayController!.toolbarMode, token: widget.token, selectedChoice: widget.overlayController?.selectedChoice, isActivityCompleteOrNullForToken: _isActivityCompleteOrNullForToken, isSelected: _isSelected, height: tokenButtonHeight, width: widget.width, textStyle: widget.textStyle, sizeAnimation: _iconSizeAnimation!, onHover: _setHovered, onTap: () => widget.overlayController!.onMorphActivitySelect( MorphSelection(widget.token, _activity!.morphFeature!), ), onMatch: _onMatch, ); } if (_heightAnimation != null && _iconSizeAnimation != null) { return AnimatedBuilder( animation: _heightAnimation!, builder: (context, child) { return MessageTokenButtonContent( activity: _activity, messageMode: widget.overlayController!.toolbarMode, token: widget.token, selectedChoice: widget.overlayController?.selectedChoice, isActivityCompleteOrNullForToken: _isActivityCompleteOrNullForToken, isSelected: _isSelected, height: _heightAnimation!.value, width: widget.width, textStyle: widget.textStyle, sizeAnimation: _iconSizeAnimation!, onHover: _setHovered, onTap: () => widget.overlayController!.onMorphActivitySelect( MorphSelection(widget.token, _activity!.morphFeature!), ), onMatch: _onMatch, ); }, ); } return const SizedBox.shrink(); } } class MessageTokenButtonContent extends StatelessWidget { final PracticeTarget? activity; final MessageMode messageMode; final PangeaToken token; final PracticeChoice? selectedChoice; final bool isActivityCompleteOrNullForToken; final bool isSelected; final double height; final double width; final TextStyle textStyle; final Animation sizeAnimation; final Function(bool)? onHover; final Function()? onTap; final Function(PracticeChoice)? onMatch; const MessageTokenButtonContent({ super.key, required this.activity, required this.messageMode, required this.token, required this.selectedChoice, required this.isActivityCompleteOrNullForToken, required this.isSelected, required this.height, required this.width, required this.textStyle, required this.sizeAnimation, this.onHover, this.onTap, this.onMatch, }); TextStyle get _emojiStyle => TextStyle( fontSize: (textStyle.fontSize ?? tokenButtonDefaultFontSize) + 4, ); static final _borderRadius = BorderRadius.circular(AppConfig.borderRadius - 4); Color _color(BuildContext context) { final bool isLight = Theme.of(context).brightness == Brightness.light; final defaultColor = isLight ? Theme.of(context).colorScheme.primary : Theme.of(context).colorScheme.primaryContainer; return activity != null && isActivityCompleteOrNullForToken ? AppConfig.gold : defaultColor; } @override Widget build(BuildContext context) { if (isActivityCompleteOrNullForToken || activity == null) { if (MessageMode.wordEmoji == messageMode) { return SizedBox( height: height, child: Text( token.vocabConstructID.userSetEmoji.firstOrNull ?? '', style: _emojiStyle, ), ); } if (MessageMode.wordMorph == messageMode && activity != null) { final morphFeature = activity!.morphFeature!; final morphTag = token.morphIdByFeature(morphFeature); if (morphTag != null) { return Tooltip( message: getGrammarCopy( category: morphFeature.toShortString(), lemma: morphTag.lemma, context: context, ), child: SizedBox( width: 24.0, child: Center( child: MorphIcon( morphFeature: morphFeature, morphTag: morphTag.lemma, ), ), ), ); } } else { return SizedBox(height: height); } } if (MessageMode.wordMorph == messageMode) { if (activity?.morphFeature == null) { return SizedBox(height: height); } return InkWell( onHover: onHover, onTap: onTap, borderRadius: _borderRadius, child: SizedBox( height: height, child: Opacity( opacity: isSelected ? 1.0 : 0.4, child: AnimatedBuilder( animation: sizeAnimation, builder: (context, child) { return Icon( Symbols.toys_and_games, color: _color(context), size: sizeAnimation.value, // Use the new animation ); }, ), ), ), ); } return DragTarget( builder: (BuildContext context, accepted, rejected) { final double colorAlpha = 0.3 + (selectedChoice != null ? 0.4 : 0.0) + (accepted.isNotEmpty ? 0.3 : 0.0); final theme = Theme.of(context); return InkWell( onTap: selectedChoice != null ? () => onMatch?.call(selectedChoice!) : null, borderRadius: _borderRadius, child: CustomPaint( painter: DottedBorderPainter( color: theme.brightness == Brightness.light ? theme.colorScheme.primary .withAlpha((colorAlpha * 255).toInt()) : theme.colorScheme.primaryContainer .withAlpha((colorAlpha * 255).toInt()), borderRadius: _borderRadius, ), child: Container( height: height, padding: const EdgeInsets.only(top: 10.0), width: max(width, 24.0), alignment: Alignment.center, decoration: BoxDecoration( color: theme.colorScheme.primary .withAlpha((max(0, colorAlpha - 0.7) * 255).toInt()), borderRadius: _borderRadius, ), ), ), ); }, onAcceptWithDetails: (details) => onMatch?.call(details.data), ); } }