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 <ggurdin@gmail.com>
This commit is contained in:
wcjord 2025-01-10 15:53:15 -05:00 committed by GitHub
parent 8cbe1ea1f7
commit 075b3da80e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 338 additions and 102 deletions

View file

@ -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",

View file

@ -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;

View file

@ -225,3 +225,12 @@ IconData getIconForMorphFeature(String feature) {
return Icons.help_outline;
}
}
List<String> getLabelsForMorphCategory(String category) {
for (final feat in morphCategoriesAndLabels.keys) {
if (feat.toLowerCase() == category.toLowerCase()) {
return morphCategoriesAndLabels[feat]!;
}
}
return [];
}

View file

@ -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<MorphologicalCenterWidget> {
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<void> 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<String> get allMorphTagsForEdit {
final List<String> 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<Color>(
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),
),
],
),
],
);
}
}

View file

@ -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,
);
}
}

View file

@ -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,
);
}
}

View file

@ -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!)],
),
);
}

View file

@ -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<WordZoomWidget> {
);
}
}
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();
}
}
}