diff --git a/lib/pages/chat/events/html_message.dart b/lib/pages/chat/events/html_message.dart index d484a6093..581e6de77 100644 --- a/lib/pages/chat/events/html_message.dart +++ b/lib/pages/chat/events/html_message.dart @@ -315,7 +315,7 @@ class HtmlMessage extends StatelessWidget { ), ), width: tokenWidth, - animate: isTransitionAnimation, + animateIn: isTransitionAnimation, practiceTarget: overlayController?.toolbarMode.associatedActivityType != null diff --git a/lib/pangea/choreographer/widgets/choice_animation.dart b/lib/pangea/choreographer/widgets/choice_animation.dart index a557568d1..d6fbbbc20 100644 --- a/lib/pangea/choreographer/widgets/choice_animation.dart +++ b/lib/pangea/choreographer/widgets/choice_animation.dart @@ -32,10 +32,6 @@ class ChoiceAnimationWidgetState extends State duration: const Duration(milliseconds: choiceArrayAnimationDuration), vsync: this, ); - - if (widget.isSelected) { - _controller.forward().then((_) => _controller.reset()); - } } @override diff --git a/lib/pangea/message_token_text/message_token_button.dart b/lib/pangea/message_token_text/message_token_button.dart index 4750dab8f..7b393c6b6 100644 --- a/lib/pangea/message_token_text/message_token_button.dart +++ b/lib/pangea/message_token_text/message_token_button.dart @@ -25,14 +25,13 @@ const double tokenButtonHeight = 40.0; const double tokenButtonDefaultFontSize = 10; const int maxEmojisPerLemma = 1; const double estimatedEmojiWidthRatio = 2; -const double estimatedEmojiHeightRatio = 1.3; class MessageTokenButton extends StatefulWidget { final MessageOverlayController? overlayController; final PangeaToken token; final TextStyle textStyle; final double width; - final bool animate; + final bool animateIn; final PracticeTarget? practiceTarget; const MessageTokenButton({ @@ -42,7 +41,7 @@ class MessageTokenButton extends StatefulWidget { required this.textStyle, required this.width, required this.practiceTarget, - this.animate = false, + this.animateIn = false, }); @override @@ -59,14 +58,20 @@ class MessageTokenButtonState extends State late Animation _iconSizeAnimation; bool _isHovered = false; + bool _isSelected = false; + bool _finishedInitialAnimation = false; + bool _wasEmpty = false; @override void initState() { super.initState(); + _setSelected(); + _controller = AnimationController( vsync: this, duration: const Duration( milliseconds: AppConfig.overlayAnimationDuration, + // seconds: 5, ), ); @@ -88,29 +93,29 @@ class MessageTokenButtonState extends State CurvedAnimation(parent: _iconSizeController, curve: Curves.easeInOut), ); - if (widget.animate) { - _controller.forward(); + _wasEmpty = _isEmpty; + + if (!_isEmpty) { + _controller.forward().then((_) { + if (mounted) setState(() => _finishedInitialAnimation = true); + }); + } else { + setState(() => _finishedInitialAnimation = true); } } - double get topPadding => 10.0; - - double get height => - widget.animate ? _heightAnimation.value : tokenButtonHeight; - @override void didUpdateWidget(covariant MessageTokenButton oldWidget) { - if (oldWidget.overlayController?.toolbarMode != - widget.overlayController?.toolbarMode || - oldWidget.overlayController?.selectedToken != - widget.overlayController?.selectedToken || - oldWidget.overlayController?.selectedMorph != - widget.overlayController?.selectedMorph || - widget.token.vocabConstructID.constructUses.points != - widget.token.vocabConstructID.constructUses.points) { - setState(() {}); - } super.didUpdateWidget(oldWidget); + _setSelected(); + if (_isEmpty != _wasEmpty) { + if (_isEmpty && _animate) { + _controller.reverse(); + } else if (!_isEmpty && _animate) { + _controller.forward(); + } + setState(() => _wasEmpty = _isEmpty); + } } @override @@ -120,18 +125,51 @@ class MessageTokenButtonState extends State super.dispose(); } - double get textSize => - widget.textStyle.fontSize ?? tokenButtonDefaultFontSize; + bool get _animate => widget.animateIn || _finishedInitialAnimation; - double get emojiSize => textSize * estimatedEmojiWidthRatio; + PracticeTarget? get _activity => widget.practiceTarget; - TextStyle get emojiStyle => widget.textStyle.copyWith( - fontSize: textSize + 4, - ); + bool get _isActivityCompleteForToken => + _activity?.isCompleteByToken( + widget.token, + _activity!.morphFeature, + ) == + true; - PracticeTarget? get activity => widget.practiceTarget; + void _setSelected() { + final selected = + widget.overlayController?.selectedMorph?.token == widget.token && + widget.overlayController?.selectedMorph?.morph == + _activity?.morphFeature; - onMatch(PracticeChoice form) { + 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( @@ -140,9 +178,7 @@ class MessageTokenButtonState extends State ); return; } - widget.overlayController!.selectedChoice = null; - widget.overlayController!.setState(() {}); - + widget.overlayController!.onChoiceSelect(null); widget.overlayController!.activity!.onMatch( widget.token, form, @@ -151,65 +187,136 @@ class MessageTokenButtonState extends State ); } - Widget get emojiView { - // if (widget.token.text.content.length == 1 || maxEmojisPerLemma == 1) { - return ShrinkableText( - text: widget.token.vocabConstructID.userSetEmoji.firstOrNull ?? '', - maxWidth: widget.width, - style: emojiStyle, - ); - // } - // return Stack( - // alignment: Alignment.center, - // children: widget.token.vocabConstructID.userSetEmoji - // .take(maxEmojisPerLemma) - // .mapIndexed( - // (index, emoji) => Positioned( - // left: min( - // index / - // widget.token.vocabConstructID.userSetEmoji.length * - // totalAvailableWidth, - // index * emojiSize, - // ), - // child: Text( - // emoji, - // style: emojiStyle, - // ), - // ), - // ) - // .toList() - // .reversed - // .toList(), - // ); + bool get _isEmpty { + final mode = widget.overlayController?.toolbarMode; + return _activity == null || + (_isActivityCompleteForToken && + ![MessageMode.wordEmoji, MessageMode.wordMorph].contains(mode)) || + (MessageMode.wordMorph == mode && _activity?.morphFeature == null); } - bool get isActivityCompleteForToken => - activity?.isCompleteByToken( - widget.token, - activity!.morphFeature, - ) == - true; + @override + Widget build(BuildContext context) { + if (widget.overlayController == null) { + return const SizedBox.shrink(); + } - Color get color { + if (!_animate) { + return MessageTokenButtonContent( + activity: _activity, + messageMode: widget.overlayController!.toolbarMode, + token: widget.token, + selectedChoice: widget.overlayController?.selectedChoice, + isComplete: _isActivityCompleteForToken, + 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, + ); + } + + return AnimatedBuilder( + animation: _heightAnimation, + builder: (context, child) { + return MessageTokenButtonContent( + activity: _activity, + messageMode: widget.overlayController!.toolbarMode, + token: widget.token, + selectedChoice: widget.overlayController?.selectedChoice, + isComplete: _isActivityCompleteForToken, + 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, + ); + }, + ); + } +} + +class MessageTokenButtonContent extends StatelessWidget { + final PracticeTarget? activity; + final MessageMode messageMode; + final PangeaToken token; + final PracticeChoice? selectedChoice; + + final bool isComplete; + 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.isComplete, + 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.copyWith( + fontSize: (textStyle.fontSize ?? tokenButtonDefaultFontSize) + 4, + ); + + static final _borderRadius = + BorderRadius.circular(AppConfig.borderRadius - 4); + + Color _color(BuildContext context) { if (activity == null) { return Theme.of(context).colorScheme.primary; } - if (isActivityCompleteForToken) { + if (isComplete) { return AppConfig.gold; } return Theme.of(context).colorScheme.primary; } - Widget get content { - final tokenActivity = activity; - if (tokenActivity == null || isActivityCompleteForToken) { - if (MessageMode.wordEmoji == widget.overlayController?.toolbarMode) { - return SizedBox(height: height, child: emojiView); + @override + Widget build(BuildContext context) { + if (activity == null) { + return SizedBox(height: height); + } + + if (isComplete) { + if (MessageMode.wordEmoji == messageMode) { + return SizedBox( + height: height, + child: ShrinkableText( + text: token.vocabConstructID.userSetEmoji.firstOrNull ?? '', + maxWidth: width, + style: _emojiStyle, + ), + ); } - if (MessageMode.wordMorph == widget.overlayController?.toolbarMode && - activity?.morphFeature != null) { + if (MessageMode.wordMorph == messageMode) { final morphFeature = activity!.morphFeature!; - final morphTag = widget.token.morphIdByFeature(morphFeature); + final morphTag = token.morphIdByFeature(morphFeature); if (morphTag != null) { return Tooltip( message: getGrammarCopy( @@ -218,7 +325,7 @@ class MessageTokenButtonState extends State context: context, ), child: SizedBox( - width: widget.width, + width: width, height: height, child: Center( child: MorphIcon( @@ -234,43 +341,28 @@ class MessageTokenButtonState extends State return SizedBox(height: height); } - if (MessageMode.wordMorph == widget.overlayController?.toolbarMode) { + if (MessageMode.wordMorph == messageMode) { if (activity?.morphFeature == null) { return SizedBox(height: height); } - final bool isSelected = - (widget.overlayController?.selectedMorph?.token == widget.token && - widget.overlayController?.selectedMorph?.morph == - activity?.morphFeature) || - _isHovered; - - // Trigger the icon size animation based on hover or selection - if (isSelected) { - _iconSizeController.forward(); - } else { - _iconSizeController.reverse(); - } - return InkWell( - onHover: (isHovered) => setState(() => _isHovered = isHovered), - onTap: () => widget.overlayController!.onMorphActivitySelect( - MorphSelection(widget.token, activity!.morphFeature!), - ), - borderRadius: borderRadius, + onHover: onHover, + onTap: onTap, + borderRadius: _borderRadius, child: Container( height: height, - width: min(widget.width, height), + width: min(width, height), alignment: Alignment.center, child: Opacity( opacity: isSelected ? 1.0 : 0.4, child: AnimatedBuilder( - animation: _iconSizeAnimation, + animation: sizeAnimation, builder: (context, child) { return Icon( Symbols.toys_and_games, - color: color, - size: _iconSizeAnimation.value, // Use the new animation + color: _color(context), + size: sizeAnimation.value, // Use the new animation ); }, ), @@ -282,61 +374,41 @@ class MessageTokenButtonState extends State return DragTarget( builder: (BuildContext context, accepted, rejected) { final double colorAlpha = 0.3 + - (widget.overlayController?.selectedChoice != null ? 0.4 : 0.0) + - (accepted.isNotEmpty || _isHovered ? 0.3 : 0.0); + (selectedChoice != null ? 0.4 : 0.0) + + (accepted.isNotEmpty ? 0.3 : 0.0); return InkWell( - onHover: (isHovered) => setState(() => _isHovered = isHovered), - onTap: widget.overlayController?.selectedChoice != null - ? () => onMatch(widget.overlayController!.selectedChoice!) + onTap: selectedChoice != null + ? () => onMatch?.call(selectedChoice!) : null, - borderRadius: borderRadius, + borderRadius: _borderRadius, child: CustomPaint( painter: DottedBorderPainter( color: Theme.of(context) .colorScheme .primary .withAlpha((colorAlpha * 255).toInt()), - borderRadius: borderRadius, + borderRadius: _borderRadius, ), child: Container( height: height, - padding: EdgeInsets.only(top: topPadding), - width: MessageMode.wordMeaning == - widget.overlayController?.toolbarMode - ? widget.width - : min(widget.width, height), + padding: const EdgeInsets.only(top: 10.0), + width: MessageMode.wordMeaning == messageMode + ? width + : min(width, height), alignment: Alignment.center, decoration: BoxDecoration( color: Theme.of(context) .colorScheme .primary .withAlpha((max(0, colorAlpha - 0.7) * 255).toInt()), - borderRadius: borderRadius, + borderRadius: _borderRadius, ), ), ), ); }, - onAcceptWithDetails: (details) => onMatch(details.data), - ); - } - - static final borderRadius = BorderRadius.circular(AppConfig.borderRadius - 4); - - @override - Widget build(BuildContext context) { - if (widget.overlayController == null) { - return const SizedBox.shrink(); - } - - if (!widget.animate) { - return content; - } - - return AnimatedBuilder( - animation: _heightAnimation, - builder: (context, child) => content, + onAcceptWithDetails: (details) => onMatch?.call(details.data), ); } } diff --git a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart index 9ef6ffb2a..183b5cfee 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart @@ -35,10 +35,6 @@ class MatchActivityCard extends StatelessWidget { ) { switch (activityType) { case ActivityTypeEnum.emoji: - return Text( - choice, - style: TextStyle(fontSize: fontSize), - ); case ActivityTypeEnum.wordMeaning: return Text( choice, diff --git a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart index ea7aa6084..c547e0c3c 100644 --- a/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart +++ b/lib/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart @@ -107,31 +107,6 @@ class PracticeMatchItemState extends State { } } - IntrinsicWidth content(BuildContext context) { - return IntrinsicWidth( - child: Container( - height: widget.fixedSize, - width: widget.fixedSize, - alignment: Alignment.center, - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: color(context).withAlpha((0.4 * 255).toInt()), - borderRadius: BorderRadius.circular(AppConfig.borderRadius), - border: isSelected - ? Border.all( - color: color(context), - width: 2, - ) - : Border.all( - color: Colors.transparent, - width: 2, - ), - ), - child: widget.content, - ), - ); - } - void onTap() { play(); isCorrect == null || !isCorrect! || widget.token == null @@ -141,11 +116,37 @@ class PracticeMatchItemState extends State { @override Widget build(BuildContext context) { + final content = Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + height: widget.fixedSize, + width: widget.fixedSize, + alignment: Alignment.center, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: color(context).withAlpha((0.4 * 255).toInt()), + borderRadius: BorderRadius.circular(AppConfig.borderRadius), + border: isSelected + ? Border.all( + color: color(context), + width: 2, + ) + : Border.all( + color: Colors.transparent, + width: 2, + ), + ), + child: widget.content, + ), + ], + ); + return LongPressDraggable( data: widget.constructForm, feedback: Material( type: MaterialType.transparency, - child: content(context), + child: content, ), delay: const Duration(milliseconds: 50), onDragStarted: onTap, @@ -153,7 +154,7 @@ class PracticeMatchItemState extends State { onHover: (isHovered) => setState(() => _isHovered = isHovered), borderRadius: BorderRadius.circular(AppConfig.borderRadius), onTap: onTap, - child: content(context), + child: content, ), ); } diff --git a/lib/pangea/toolbar/widgets/message_selection_overlay.dart b/lib/pangea/toolbar/widgets/message_selection_overlay.dart index ad4a16dfb..4ec8c1667 100644 --- a/lib/pangea/toolbar/widgets/message_selection_overlay.dart +++ b/lib/pangea/toolbar/widgets/message_selection_overlay.dart @@ -370,7 +370,7 @@ class MessageOverlayController extends State } } - void onChoiceSelect(PracticeChoice choice, [bool force = false]) { + void onChoiceSelect(PracticeChoice? choice, [bool force = false]) { if (selectedChoice == choice && !force) { selectedChoice = null; } else { diff --git a/lib/pangea/toolbar/widgets/message_selection_positioner.dart b/lib/pangea/toolbar/widgets/message_selection_positioner.dart index 5f270098d..95c298461 100644 --- a/lib/pangea/toolbar/widgets/message_selection_positioner.dart +++ b/lib/pangea/toolbar/widgets/message_selection_positioner.dart @@ -145,6 +145,8 @@ class MessageSelectionPositionerState extends State } void _setCenteredMessageSize(RenderBox renderBox) { + if (_centeredMessageCompleter.isCompleted) return; + _centeredMessageSize = renderBox.size; final offset = renderBox.localToGlobal(Offset.zero); _centeredMessageOffset = Offset( @@ -406,12 +408,17 @@ class MessageSelectionPositionerState extends State if (hasHeaderOverflow) { final difference = topOffset - (_headerHeight + AppConfig.toolbarSpacing); + double newBottomOffset = _mediaQuery!.size.height - + _originalMessageOffset.dy + + difference - + _originalMessageSize.height; + if (newBottomOffset < _footerHeight + AppConfig.toolbarSpacing) { + newBottomOffset = _footerHeight + AppConfig.toolbarSpacing; + } + return Offset( _ownMessage ? _messageRightOffset : _messageLeftOffset, - _mediaQuery!.size.height - - _originalMessageOffset.dy + - difference - - _originalMessageSize.height, + newBottomOffset, ); } else { final difference = diff --git a/lib/pangea/toolbar/widgets/message_token_text.dart b/lib/pangea/toolbar/widgets/message_token_text.dart index 35c71a4d2..e9d1843ec 100644 --- a/lib/pangea/toolbar/widgets/message_token_text.dart +++ b/lib/pangea/toolbar/widgets/message_token_text.dart @@ -225,7 +225,7 @@ class MessageTextWidget extends StatelessWidget { overlayController: overlayController, textStyle: renderer.style(context), width: tokenWidth, - animate: isTransitionAnimation, + animateIn: isTransitionAnimation, practiceTarget: overlayController ?.toolbarMode.associatedActivityType != null diff --git a/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart index 2ab32a3c6..cc7bf514b 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart @@ -312,7 +312,7 @@ class PracticeActivityCardState extends State { return Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - if (activityWidget != null) activityWidget!, + if (activityWidget != null && !fetchingActivity) activityWidget!, // Conditionally show the darkening and progress indicator based on the loading state if (!savoringTheJoy && fetchingActivity) ...[ // Circular progress indicator in the center