feat: underline new tokens and animate collecting them on click

This commit is contained in:
avashilling 2025-06-26 13:04:33 -04:00
parent 9a4bb6e88c
commit bf29b28364
8 changed files with 237 additions and 28 deletions

View file

@ -4589,6 +4589,7 @@
"constructUseIncMDesc": "Incorrect in grammar activity",
"constructUseIgnMDesc": "Ignored in grammar activity",
"constructUseEmojiDesc": "Correct in emoji activity",
"constructUseCollected": "Collected in chat",
"constructUseNanDesc": "Not applicable",
"xpIntoLevel": "{currentXP} / {maxXP} XP",
"@xpIntoLevel": {

View file

@ -1,14 +1,4 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_highlighter/flutter_highlighter.dart';
import 'package:flutter_highlighter/themes/shades-of-purple.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as parser;
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/common/utils/any_state_holder.dart';
@ -24,6 +14,15 @@ import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_highlighter/flutter_highlighter.dart';
import 'package:flutter_highlighter/themes/shades-of-purple.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:html/dom.dart' as dom;
import 'package:html/parser.dart' as parser;
import 'package:matrix/matrix.dart';
import '../../../utils/url_launcher.dart';
class HtmlMessage extends StatelessWidget {
@ -324,6 +323,10 @@ class HtmlMessage extends StatelessWidget {
? isSelected!.call(token)
: false;
final isNew = token != null &&
overlayController != null &&
overlayController!.isNewToken(token);
final tokenWidth = renderer.tokenTextWidthForContainer(
context,
node.text,
@ -352,6 +355,7 @@ class HtmlMessage extends StatelessWidget {
color: renderer.backgroundColor(
context,
selected,
isNew,
),
),
width: tokenWidth,
@ -386,6 +390,7 @@ class HtmlMessage extends StatelessWidget {
color: renderer.backgroundColor(
context,
selected,
isNew,
),
),
linkStyle: linkStyle,
@ -528,6 +533,7 @@ class HtmlMessage extends StatelessWidget {
color: renderer.backgroundColor(
context,
false,
false,
),
),
),
@ -543,6 +549,7 @@ class HtmlMessage extends StatelessWidget {
color: renderer.backgroundColor(
context,
false,
false,
),
),
// Pangea#

View file

@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:flutter/material.dart';
enum ConstructUseTypeEnum {
/// produced in chat by user, igc was run, and we've judged it to be a correct use
@ -66,6 +65,9 @@ enum ConstructUseTypeEnum {
incMM,
ignMM,
/// lemma collected by clicking on it
click,
/// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client
nan
}
@ -135,6 +137,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
return L10n.of(context).constructUseIncMmDesc;
case ConstructUseTypeEnum.ignMM:
return L10n.of(context).constructUseIgnMmDesc;
case ConstructUseTypeEnum.click:
return L10n.of(context).constructUseCollected;
case ConstructUseTypeEnum.nan:
return L10n.of(context).constructUseNanDesc;
}
@ -185,6 +189,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.unk:
case ConstructUseTypeEnum.nan:
return Icons.help;
case ConstructUseTypeEnum.click:
return Icons.format_color_text;
}
}
@ -211,6 +217,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corIGC:
case ConstructUseTypeEnum.corL:
case ConstructUseTypeEnum.click:
return 2;
case ConstructUseTypeEnum.corIt:
@ -279,6 +286,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incMM:
case ConstructUseTypeEnum.ignMM:
case ConstructUseTypeEnum.em:
case ConstructUseTypeEnum.click:
case ConstructUseTypeEnum.nan:
return false;
}
@ -318,6 +326,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incMM:
case ConstructUseTypeEnum.ignMM:
case ConstructUseTypeEnum.em:
case ConstructUseTypeEnum.click:
return LearningSkillsEnum.reading;
case ConstructUseTypeEnum.pvm:
return LearningSkillsEnum.speaking;
@ -364,6 +373,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.ignL:
case ConstructUseTypeEnum.ignM:
case ConstructUseTypeEnum.ignMM:
case ConstructUseTypeEnum.click:
case ConstructUseTypeEnum.nan:
return null;
}

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:flutter/material.dart';
class TokenRenderingUtil {
final PangeaMessageEvent? pangeaMessageEvent;
@ -85,7 +85,10 @@ class TokenRenderingUtil {
}
}
Color backgroundColor(BuildContext context, bool selected) {
Color backgroundColor(BuildContext context, bool selected, bool isNew) {
if (isNew) {
return AppConfig.success;
}
return selected
? Theme.of(context).colorScheme.primary
: Colors.white.withAlpha(0);

View file

@ -2,15 +2,13 @@ import 'dart:async';
import 'dart:developer';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/utils/overlay.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
@ -35,6 +33,10 @@ import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_sel
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_positioner.dart';
import 'package:fluffychat/pangea/toolbar/widgets/reading_assistance_content.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';
/// Controls data at the top level of the toolbar (mainly token / toolbar mode selection)
class MessageSelectionOverlay extends StatefulWidget {
@ -103,6 +105,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
double maxWidth = AppConfig.toolbarMinWidth;
List<PangeaToken> newTokens = [];
/////////////////////////////////////
/// Lifecycle
/////////////////////////////////////
@ -114,6 +118,12 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
WidgetsBinding.instance.addPostFrameCallback(
(_) => widget.chatController.setSelectedEvent(event),
);
newTokens = pangeaMessageEvent?.messageDisplayRepresentation?.tokens
?.where((token) {
return token.lemma.saveVocab == true &&
token.vocabConstruct.uses.isEmpty;
}).toList() ??
[];
}
@override
@ -569,6 +579,42 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
}
updateSelectedSpan(token.text);
Future.delayed(const Duration(seconds: 2), () {
if (isNewToken(token)) {
MatrixState.pangeaController.putAnalytics.setState(
AnalyticsStream(
eventId: event.eventId,
roomId: event.room.id,
constructs: [
OneConstructUse(
useType: ConstructUseTypeEnum.click,
lemma: token.lemma.text,
constructType: ConstructTypeEnum.vocab,
metadata: ConstructUseMetaData(
roomId: event.room.id,
timeStamp: DateTime.now(),
eventId: event.eventId,
),
category: token.pos,
form: token.text.content,
xp: ConstructUseTypeEnum.click.pointValue,
),
],
targetID: token.text.uniqueKey,
),
);
// Remove the token from newTokens so it is no longer highlighted as "new"
setState(() {
newTokens.removeWhere(
(t) =>
t.text.offset == token.text.offset &&
t.text.length == token.text.length,
);
debugPrint("$token.text has been removed from newTokens list.");
});
}
});
}
/// Whether the given token is currently selected or highlighted
@ -578,6 +624,15 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
return isSelected;
}
bool isNewToken(PangeaToken token) {
if (newTokens.isEmpty) return false;
return newTokens.any(
(t) =>
t.text.offset == token.text.offset &&
t.text.length == token.text.length,
);
}
bool isTokenHighlighted(PangeaToken token) {
if (_highlightedTokens == null) return false;
return _highlightedTokens!.any(

View file

@ -1,9 +1,5 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:matrix/matrix_api_lite/model/message_types.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
@ -16,6 +12,8 @@ import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_act
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix_api_lite/model/message_types.dart';
const double minCardHeight = 70;
@ -120,6 +118,8 @@ class ReadingAssistanceContentState extends State<ReadingAssistanceContent> {
token: widget.overlayController.selectedToken!,
messageEvent: widget.overlayController.pangeaMessageEvent!,
overlayController: widget.overlayController,
wordIsNew: widget.overlayController
.isNewToken(widget.overlayController.selectedToken!),
);
}
}

View file

@ -0,0 +1,121 @@
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:flutter/material.dart';
class NewWordOverlay extends StatefulWidget {
final Widget child;
final bool show;
final Color overlayColor;
final VoidCallback? onComplete;
const NewWordOverlay({
super.key,
required this.child,
required this.show,
required this.overlayColor,
this.onComplete,
});
@override
State<NewWordOverlay> createState() => _NewWordOverlayState();
}
class _NewWordOverlayState extends State<NewWordOverlay>
with TickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _xpScaleAnim;
late final Animation<double> _fadeAnim;
late final Animation<Alignment> _alignmentAnim;
late final Animation<Offset> _offsetAnim;
final bool _showOverlay = true;
Widget xpSeedWidget = const SizedBox();
Widget? get svg => ConstructLevelEnum.seeds.icon();
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2500),
);
_xpScaleAnim = CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.bounceOut),
);
_fadeAnim = CurvedAnimation(
parent: _controller,
curve: const Interval(0.7, 1.0, curve: Curves.easeOut),
);
// Alignment animation: stays center until 0.5, then animates to topRight
_alignmentAnim = AlignmentTween(
begin: Alignment.center,
end: Alignment.topRight,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeInOut),
),
);
// Offset animation: stays at Offset.zero, then moves up and right
_offsetAnim = Tween<Offset>(
begin: Offset.zero,
end: const Offset(-0.1, -0.1),
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeInOut),
),
);
xpSeedWidget = Container(
child: svg,
);
if (mounted) {
_controller.forward();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_showOverlay) return widget.child;
return Stack(
children: [
widget.child,
Positioned.fill(
child: FadeTransition(
opacity: ReverseAnimation(_fadeAnim),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Container(
color: widget.overlayColor,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Align(
alignment: _alignmentAnim.value,
child: FractionalTranslation(
translation: _offsetAnim.value,
child: ScaleTransition(
scale: _xpScaleAnim,
child: xpSeedWidget,
),
),
);
},
),
),
),
),
),
],
);
}
}

View file

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart';
@ -15,18 +13,22 @@ import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/new_word_overlay.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class WordZoomWidget extends StatelessWidget {
final PangeaToken token;
final PangeaMessageEvent messageEvent;
final MessageOverlayController overlayController;
final bool wordIsNew;
const WordZoomWidget({
super.key,
required this.token,
required this.messageEvent,
required this.overlayController,
required this.wordIsNew,
});
PangeaToken get _selectedToken => overlayController.selectedToken!;
@ -41,7 +43,9 @@ class WordZoomWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
final isNewWord = wordIsNew;
final overlayColor = Theme.of(context).scaffoldBackgroundColor;
Widget card = Container(
padding: const EdgeInsets.all(12.0),
constraints: const BoxConstraints(
minHeight: AppConfig.toolbarMinHeight - 8,
@ -241,5 +245,13 @@ class WordZoomWidget extends StatelessWidget {
),
),
);
if (isNewWord) {
card = NewWordOverlay(
show: true,
overlayColor: overlayColor,
child: card,
);
}
return card;
}
}