From 075b3da80ee6ca1f5438069dbe62ae0f72cea78e Mon Sep 17 00:00:00 2001 From: wcjord <32568597+wcjord@users.noreply.github.com> Date: Fri, 10 Jan 2025 15:53:15 -0500 Subject: [PATCH] Morph edits (#1398) * feat: allow user to edit morph tags * fix: not local choreeoooooo * fix: reverting pangea representation * dev: remove first implementation --------- Co-authored-by: ggurdin --- assets/l10n/intl_en.arb | 2 + lib/config/app_config.dart | 2 +- .../morph_categories_and_labels.dart | 9 + .../morphs/morphological_center_widget.dart | 247 ++++++++++++++++++ .../morphs/morphological_list_item.dart | 36 +++ .../morphological_list_widget.dart} | 37 +-- .../word_zoom/word_zoom_center_widget.dart | 66 +++-- .../widgets/word_zoom/word_zoom_widget.dart | 41 +-- 8 files changed, 338 insertions(+), 102 deletions(-) create mode 100644 lib/pangea/widgets/word_zoom/morphs/morphological_center_widget.dart create mode 100644 lib/pangea/widgets/word_zoom/morphs/morphological_list_item.dart rename lib/pangea/widgets/word_zoom/{morphological_widget.dart => morphs/morphological_list_widget.dart} (69%) diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 1e98bde02..beb8fc820 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4230,6 +4230,7 @@ "reportContentIssueTitle": "Report content issue", "feedback": "Optional feedback", "reportContentIssueDescription": "Uh oh! AI can faciliate personalized learning experiences but... also hallucinates. Please provide any feedback you have and we'll try again.", + "changeContent": "Uh oh! AI can faciliate personalized learning experiences but... also hallucinates. What should it be?", "clickTheWordAgainToDeselect": "Click the selected word to deselect it.", "l2SupportNa": "Not Available", "l2SupportAlpha": "Alpha", @@ -4699,6 +4700,7 @@ "downloading": "Downloading...", "failedFetchUserAnalytics": "Failed to download user analytics", "downloadComplete": "Download complete!", + "editMorphologicalLabel": "Pangea Bot makes mistakes too! What should this label be?", "dataAvailable": "Data availability", "lemmasNeverUsedCorrectly": "Number of lemmas used correctly 0 times", "available": "Available", diff --git a/lib/config/app_config.dart b/lib/config/app_config.dart index 0e2dbd57d..7256cba43 100644 --- a/lib/config/app_config.dart +++ b/lib/config/app_config.dart @@ -23,7 +23,7 @@ abstract class AppConfig { static const double messageFontSize = 16.0; static const bool allowOtherHomeservers = true; static const bool enableRegistration = true; - static const double toolbarMaxHeight = 300.0; + static const double toolbarMaxHeight = 440.0; static const double toolbarMinHeight = 175.0; static const double toolbarMinWidth = 350.0; static const double toolbarButtonsHeight = 50.0; diff --git a/lib/pangea/constants/morph_categories_and_labels.dart b/lib/pangea/constants/morph_categories_and_labels.dart index fe9ae2218..44e62440e 100644 --- a/lib/pangea/constants/morph_categories_and_labels.dart +++ b/lib/pangea/constants/morph_categories_and_labels.dart @@ -225,3 +225,12 @@ IconData getIconForMorphFeature(String feature) { return Icons.help_outline; } } + +List getLabelsForMorphCategory(String category) { + for (final feat in morphCategoriesAndLabels.keys) { + if (feat.toLowerCase() == category.toLowerCase()) { + return morphCategoriesAndLabels[feat]!; + } + } + return []; +} diff --git a/lib/pangea/widgets/word_zoom/morphs/morphological_center_widget.dart b/lib/pangea/widgets/word_zoom/morphs/morphological_center_widget.dart new file mode 100644 index 000000000..8b7b415b9 --- /dev/null +++ b/lib/pangea/widgets/word_zoom/morphs/morphological_center_widget.dart @@ -0,0 +1,247 @@ +// stateful widget that displays morphological label and a shimmer effect while the text is loading +// takes a token and morphological feature as input + +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; + +import 'package:fluffychat/pangea/constants/morph_categories_and_labels.dart'; +import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.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/tokens_event_content_model.dart'; +import 'package:fluffychat/pangea/utils/bot_style.dart'; +import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart'; +import 'package:fluffychat/widgets/future_loading_dialog.dart'; + +class MorphologicalCenterWidget extends StatefulWidget { + final PangeaToken token; + final String morphFeature; + final PangeaMessageEvent pangeaMessageEvent; + + const MorphologicalCenterWidget({ + required this.token, + required this.morphFeature, + required this.pangeaMessageEvent, + super.key, + }); + + @override + MorphologicalCenterWidgetState createState() => + MorphologicalCenterWidgetState(); +} + +class MorphologicalCenterWidgetState extends State { + bool editMode = false; + + /// the morphological tag that the user has selected in edit mode + String selectedMorphTag = ""; + + void resetMorphTag() => setState( + () => selectedMorphTag = widget.token.morph[widget.morphFeature]!, + ); + + @override + void didUpdateWidget(MorphologicalCenterWidget oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.token != oldWidget.token || + widget.morphFeature != oldWidget.morphFeature) { + resetMorphTag(); + setState(() => editMode = false); + } + } + + @override + void initState() { + super.initState(); + resetMorphTag(); + } + + void enterEditMode() { + setState(() { + editMode = true; + }); + } + + PangeaMessageEvent get pm => widget.pangeaMessageEvent; + + Future confirmChanges() async { + try { + // NOTE: it is not clear how this would work if the user was not editing the originalSent tokens + // this case would only happen in immersion mode which is disabled until further notice + // this flow assumes that the user is editing the originalSent tokens + // if not, we'll get an error and we'll cross that bridge + + // make a copy of the original tokens + final existingTokens = pm.originalSent!.tokens! + .map((token) => PangeaToken.fromJson(token.toJson())) + .toList(); + + // change the morphological tag in the selected token + final tokenIndex = existingTokens + .indexWhere((token) => token.text.offset == widget.token.text.offset); + if (tokenIndex == -1) { + throw Exception("Token not found in message"); + } + existingTokens[tokenIndex].morph[widget.morphFeature] = selectedMorphTag; + + // send a new message as an edit to original message to the server + // including the new tokens + await pm.room.pangeaSendTextEvent( + pm.messageDisplayText, + editEventId: pm.eventId, + originalSent: pm.originalSent?.content, + originalWritten: pm.originalWritten?.content, + tokensSent: PangeaMessageTokens(tokens: existingTokens), + tokensWritten: pm.originalWritten?.tokens != null + ? PangeaMessageTokens(tokens: pm.originalWritten!.tokens!) + : null, + choreo: pm.originalSent?.choreo, + ); + } catch (e) { + SnackBar( + content: Text(L10n.of(context).oopsSomethingWentWrong), + ); + ErrorHandler.logError( + e: e, + data: { + "selectedMorphTag": selectedMorphTag, + "morphFeature": widget.morphFeature, + "token": widget.token.toJson(), + "pangeaMessageEvent": widget.pangeaMessageEvent.event.content, + }, + ); + } + } + + List get allMorphTagsForEdit { + final List tags = getLabelsForMorphCategory(widget.morphFeature) + .where((tag) => !["punct", "space", "sym", "x", "other"] + .contains(tag.toLowerCase())) + .toList(); + + // as long as the feature is not POS, add a nan tag + // this will allow the user to remove the feature from the tags + if (widget.morphFeature.toLowerCase() != "pos") { + tags.add(L10n.of(context).constructUseNanDesc); + } + + return tags; + } + + @override + Widget build(BuildContext context) { + if (!editMode) { + return GestureDetector( + onLongPress: enterEditMode, + onDoubleTap: enterEditMode, + child: Text( + getGrammarCopy( + category: widget.morphFeature, + lemma: widget.token.morph[widget.morphFeature], + context: context, + ) ?? + widget.token.morph[widget.morphFeature]!, + textAlign: TextAlign.center, + ), + ); + } + + return Column( + children: [ + Text( + L10n.of(context).editMorphologicalLabel, + textAlign: TextAlign.center, + ), + const SizedBox(height: 10), + Container( + constraints: const BoxConstraints(maxWidth: 400, maxHeight: 170), + child: Scrollbar( + thumbVisibility: true, + child: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Wrap( + alignment: WrapAlignment.center, + children: allMorphTagsForEdit.map((tag) { + return Container( + margin: const EdgeInsets.all(2), + padding: EdgeInsets.zero, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10)), + border: Border.all( + color: selectedMorphTag == tag + ? Theme.of(context).colorScheme.primary + : Colors.transparent, + style: BorderStyle.solid, + width: 2.0, + ), + ), + child: TextButton( + style: ButtonStyle( + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(horizontal: 7), + ), + backgroundColor: selectedMorphTag == tag + ? WidgetStateProperty.all( + Theme.of(context) + .colorScheme + .primary + .withAlpha(50), + ) + : null, + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ), + onPressed: () { + setState(() => selectedMorphTag = tag); + }, + child: Text( + getGrammarCopy( + category: widget.morphFeature, + lemma: tag, + context: context, + ) ?? + tag, + style: BotStyle.text(context), + textAlign: TextAlign.center, + ), + ), + ); + }).toList(), + ), + ), + ), + ), + const SizedBox(height: 10), + Row( + children: [ + //cancel button + ElevatedButton( + onPressed: () { + setState(() { + editMode = false; + }); + }, + child: Text(L10n.of(context).cancel), + ), + const SizedBox(width: 10), + ElevatedButton( + onPressed: + selectedMorphTag == widget.token.morph[widget.morphFeature] + ? null + : () => showFutureLoadingDialog( + context: context, + future: confirmChanges, + ), + child: Text(L10n.of(context).saveChanges), + ), + ], + ), + ], + ); + } +} diff --git a/lib/pangea/widgets/word_zoom/morphs/morphological_list_item.dart b/lib/pangea/widgets/word_zoom/morphs/morphological_list_item.dart new file mode 100644 index 000000000..ec6c1de5a --- /dev/null +++ b/lib/pangea/widgets/word_zoom/morphs/morphological_list_item.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/pangea/enum/analytics/morph_categories_enum.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/word_zoom_activity_button.dart'; + +class MorphologicalListItem extends StatelessWidget { + final Function(String) onPressed; + final String morphCategory; + final IconData icon; + + final bool isUnlocked; + final bool isSelected; + + const MorphologicalListItem({ + required this.onPressed, + required this.morphCategory, + required this.icon, + this.isUnlocked = true, + this.isSelected = false, + super.key, + }); + + @override + Widget build(BuildContext context) { + return WordZoomActivityButton( + icon: Icon(icon), + isSelected: isSelected, + onPressed: () => onPressed(morphCategory), + tooltip: getMorphologicalCategoryCopy( + morphCategory, + context, + ), + opacity: (isSelected || !isUnlocked) ? 1 : 0.5, + ); + } +} diff --git a/lib/pangea/widgets/word_zoom/morphological_widget.dart b/lib/pangea/widgets/word_zoom/morphs/morphological_list_widget.dart similarity index 69% rename from lib/pangea/widgets/word_zoom/morphological_widget.dart rename to lib/pangea/widgets/word_zoom/morphs/morphological_list_widget.dart index 5f95bd987..d7ee01ead 100644 --- a/lib/pangea/widgets/word_zoom/morphological_widget.dart +++ b/lib/pangea/widgets/word_zoom/morphs/morphological_list_widget.dart @@ -1,9 +1,8 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/constants/morph_categories_and_labels.dart'; -import 'package:fluffychat/pangea/enum/analytics/morph_categories_enum.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity/word_zoom_activity_button.dart'; +import 'package:fluffychat/pangea/widgets/word_zoom/morphs/morphological_list_item.dart'; class ActivityMorph { final String morphFeature; @@ -72,7 +71,7 @@ class MorphologicalListWidget extends StatelessWidget { children: _visibleMorphs.map((morph) { return Padding( padding: const EdgeInsets.all(2.0), - child: MorphologicalActivityButton( + child: MorphologicalListItem( onPressed: setMorphFeature, morphCategory: morph.morphFeature, icon: getIconForMorphFeature(morph.morphFeature), @@ -84,35 +83,3 @@ class MorphologicalListWidget extends StatelessWidget { ); } } - -class MorphologicalActivityButton extends StatelessWidget { - final Function(String) onPressed; - final String morphCategory; - final IconData icon; - - final bool isUnlocked; - final bool isSelected; - - const MorphologicalActivityButton({ - required this.onPressed, - required this.morphCategory, - required this.icon, - this.isUnlocked = true, - this.isSelected = false, - super.key, - }); - - @override - Widget build(BuildContext context) { - return WordZoomActivityButton( - icon: Icon(icon), - isSelected: isSelected, - onPressed: () => onPressed(morphCategory), - tooltip: getMorphologicalCategoryCopy( - morphCategory, - context, - ), - opacity: (isSelected || !isUnlocked) ? 1 : 0.5, - ); - } -} diff --git a/lib/pangea/widgets/word_zoom/word_zoom_center_widget.dart b/lib/pangea/widgets/word_zoom/word_zoom_center_widget.dart index 32c60f17d..720ca05b8 100644 --- a/lib/pangea/widgets/word_zoom/word_zoom_center_widget.dart +++ b/lib/pangea/widgets/word_zoom/word_zoom_center_widget.dart @@ -1,9 +1,14 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; import 'package:fluffychat/pangea/widgets/word_zoom/lemma_meaning_widget.dart'; +import 'package:fluffychat/pangea/widgets/word_zoom/morphs/morphological_center_widget.dart'; import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart'; class WordZoomCenterWidget extends StatelessWidget { @@ -22,17 +27,44 @@ class WordZoomCenterWidget extends StatelessWidget { super.key, }); + PangeaToken get token => wordDetailsController.widget.token; + + Widget content(BuildContext context, WordZoomSelection selectionType) { + switch (selectionType) { + case WordZoomSelection.morph: + if (selectedMorphFeature == null) { + debugger(when: kDebugMode); + return const Text("Morphological feature is null"); + } + return MorphologicalCenterWidget( + token: token, + morphFeature: selectedMorphFeature!, + pangeaMessageEvent: wordDetailsController.widget.messageEvent, + ); + case WordZoomSelection.lemma: + return Text(token.lemma.text, textAlign: TextAlign.center); + case WordZoomSelection.emoji: + return token.getEmoji() != null + ? Text(token.getEmoji()!) + : const Text("emoji is null"); + case WordZoomSelection.meaning: + return LemmaMeaningWidget( + lemma: + token.lemma.text.isNotEmpty ? token.lemma.text : token.lemma.form, + pos: token.pos, + langCode: + wordDetailsController.widget.messageEvent.messageDisplayLangCode, + ); + } + } + @override Widget build(BuildContext context) { - if (selectionType == null) { - return const ToolbarContentLoadingIndicator(); - } - if (shouldDoActivity || locked) { return PracticeActivityCard( pangeaMessageEvent: wordDetailsController.widget.messageEvent, targetTokensAndActivityType: TargetTokensAndActivityType( - tokens: [wordDetailsController.widget.token], + tokens: [token], activityType: selectionType!.activityType, ), overlayController: wordDetailsController.widget.overlayController, @@ -41,20 +73,8 @@ class WordZoomCenterWidget extends StatelessWidget { ); } - if (selectionType == WordZoomSelection.meaning) { - return Center( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: LemmaMeaningWidget( - lemma: wordDetailsController.widget.token.lemma.text.isNotEmpty - ? wordDetailsController.widget.token.lemma.text - : wordDetailsController.widget.token.lemma.form, - pos: wordDetailsController.widget.token.pos, - langCode: wordDetailsController - .widget.messageEvent.messageDisplayLangCode, - ), - ), - ); + if (selectionType == null) { + return const ToolbarContentLoadingIndicator(); } return Padding( @@ -62,13 +82,7 @@ class WordZoomCenterWidget extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, - children: [ - ActivityAnswerWidget( - token: wordDetailsController.widget.token, - selectionType: selectionType!, - selectedMorphFeature: selectedMorphFeature, - ), - ], + children: [content(context, selectionType!)], ), ); } diff --git a/lib/pangea/widgets/word_zoom/word_zoom_widget.dart b/lib/pangea/widgets/word_zoom/word_zoom_widget.dart index 0bf32d3e7..cbd5da1d3 100644 --- a/lib/pangea/widgets/word_zoom/word_zoom_widget.dart +++ b/lib/pangea/widgets/word_zoom/word_zoom_widget.dart @@ -6,13 +6,12 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/enum/activity_type_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/utils/grammar/get_grammar_copy.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/emoji_practice_button.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/word_text_with_audio_button.dart'; import 'package:fluffychat/pangea/widgets/word_zoom/lemma_widget.dart'; -import 'package:fluffychat/pangea/widgets/word_zoom/morphological_widget.dart'; +import 'package:fluffychat/pangea/widgets/word_zoom/morphs/morphological_list_widget.dart'; import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_center_widget.dart'; enum WordZoomSelection { @@ -280,41 +279,3 @@ class WordZoomWidgetState extends State { ); } } - -class ActivityAnswerWidget extends StatelessWidget { - final PangeaToken token; - final WordZoomSelection selectionType; - final String? selectedMorphFeature; - - const ActivityAnswerWidget({ - super.key, - required this.token, - required this.selectionType, - required this.selectedMorphFeature, - }); - - @override - Widget build(BuildContext context) { - switch (selectionType) { - case WordZoomSelection.morph: - if (selectedMorphFeature == null) { - return const Text("There should be a selected morph feature"); - } - final String morphTag = token.morph[selectedMorphFeature!]; - final copy = getGrammarCopy( - category: selectedMorphFeature!, - lemma: morphTag, - context: context, - ); - return Text(copy ?? morphTag, textAlign: TextAlign.center); - case WordZoomSelection.lemma: - return Text(token.lemma.text, textAlign: TextAlign.center); - case WordZoomSelection.emoji: - return token.getEmoji() != null - ? Text(token.getEmoji()!) - : const Text("emoji is null"); - case WordZoomSelection.meaning: - return const SizedBox(); - } - } -}