feat: allow users to give token feedback in word card

This commit is contained in:
wcjord 2025-10-06 11:13:39 -04:00 committed by GitHub
parent df4cda0875
commit 9790d2e56d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 630 additions and 30 deletions

View file

@ -5304,6 +5304,9 @@
"activityAnalyticsListBody": "These are your completed activities! After finishing activities, you can view them here.",
"languageMismatchTitle": "Language mismatch",
"languageMismatchDesc": "Your target language doesn't match the language of this activity. Update your target language?",
"reportWordIssueTooltip": "Report word information issue",
"tokenInfoFeedbackDialogTitle": "Word Information Feedback",
"tokenInfoFeedbackDialogDesc": "AI makes mistakes. Please describe any issues you found with the information above.",
"noPublicCoursesFound": "No public courses found. Would you like to create one?",
"noCourseTemplatesFound": "We couldn't find any courses for your target language. You can chat with Pangea Bot in the meantime, and check back later for more courses."
}

View file

@ -69,6 +69,8 @@ class PApiUrls {
static String activityFeedback =
"${PApiUrls._choreoEndpoint}/activity_plan/feedback";
static String tokenFeedback = "${PApiUrls._choreoEndpoint}/token/feedback";
static String morphFeaturesAndTags = "${PApiUrls._choreoEndpoint}/morphs";
static String constructSummary =
"${PApiUrls._choreoEndpoint}/construct_summary";

View file

@ -9,14 +9,14 @@ class LemmaInfoRequest {
final String lemmaLang;
final String userL1;
ContentFeedback<LemmaInfoResponse>? feedback;
List<ContentFeedback<LemmaInfoResponse>> feedback;
LemmaInfoRequest({
required String partOfSpeech,
required String lemmaLang,
required this.userL1,
required this.lemma,
this.feedback,
this.feedback = const [],
}) : partOfSpeech = partOfSpeech.toLowerCase(),
lemmaLang = lemmaLang.toLowerCase();
@ -26,7 +26,7 @@ class LemmaInfoRequest {
'part_of_speech': partOfSpeech,
'lemma_lang': lemmaLang,
'user_l1': userL1,
'feedback': feedback?.toJson(),
'feedback': feedback.map((e) => e.toJson()).toList(),
};
}

View file

@ -18,12 +18,12 @@ class PhoneticTranscriptionRepo {
static final GetStorage _storage =
GetStorage('phonetic_transcription_storage');
static void set(
static Future<void> set(
PhoneticTranscriptionRequest request,
PhoneticTranscriptionResponse response,
) {
) async {
response.expireAt ??= DateTime.now().add(const Duration(days: 100));
_storage.write(request.storageKey, response.toJson());
await _storage.write(request.storageKey, response.toJson());
}
static Future<PhoneticTranscriptionResponse> _fetch(

View file

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/bot/widgets/bot_face_svg.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_dialog.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_request.dart';
class TokenInfoFeedbackButton extends StatelessWidget {
final TokenInfoFeedbackRequestData requestData;
final String langCode;
final PangeaMessageEvent event;
final VoidCallback onUpdate;
const TokenInfoFeedbackButton({
super.key,
required this.requestData,
required this.langCode,
required this.event,
required this.onUpdate,
});
Future<void> _submitFeedback(BuildContext context) async {
final resp = await showDialog(
context: context,
builder: (context) => TokenInfoFeedbackDialog(
requestData: requestData,
langCode: langCode,
event: event,
onUpdate: onUpdate,
),
);
if (resp != null && resp is String) {
_showSuccessSnackBar(resp, context);
}
}
void _showSuccessSnackBar(String message, BuildContext context) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const BotFace(
width: 30,
expression: BotExpression.idle,
),
const SizedBox(width: 12),
Expanded(
child: Text(message),
),
],
),
duration: const Duration(seconds: 30),
action: SnackBarAction(
label: L10n.of(context).close,
onPressed: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
},
),
),
);
}
@override
Widget build(BuildContext context) {
return IconButton(
icon: const Icon(Icons.flag_outlined),
onPressed: () => _submitFeedback(context),
tooltip: L10n.of(context).reportWordIssueTooltip,
);
}
}

View file

@ -0,0 +1,279 @@
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_repo.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_request.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_response.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_repo.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_request.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_response.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/word_zoom_widget.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class TokenInfoFeedbackDialog extends StatefulWidget {
final TokenInfoFeedbackRequestData requestData;
final String langCode;
final PangeaMessageEvent event;
final VoidCallback onUpdate;
const TokenInfoFeedbackDialog({
super.key,
required this.requestData,
required this.langCode,
required this.event,
required this.onUpdate,
});
@override
State<TokenInfoFeedbackDialog> createState() =>
_TokenInfoFeedbackDialogState();
}
class _TokenInfoFeedbackDialogState extends State<TokenInfoFeedbackDialog> {
final TextEditingController _feedbackController = TextEditingController();
@override
void dispose() {
_feedbackController.dispose();
super.dispose();
}
Future<String> _submitFeedback() async {
final request = TokenInfoFeedbackRequest(
userFeedback: _feedbackController.text,
data: widget.requestData,
);
final TokenInfoFeedbackResponse response =
await TokenInfoFeedbackRepo.submitFeedback(request);
final originalToken =
widget.requestData.tokens[widget.requestData.selectedToken];
final token = response.updatedToken ?? originalToken;
// first, update lemma info if changed
if (response.updatedLemmaInfo != null) {
await _updateLemmaInfo(
token,
response.updatedLemmaInfo!,
);
}
// second, update the phonetic info if changed
if (response.updatedPhonetics != null) {
await _updatePhoneticTranscription(
response.updatedPhonetics!,
);
}
final originalSent = widget.event.originalSent;
// if no other changes, just return the message
final hasTokenUpdate = response.updatedToken != null;
final hasLangUpdate = originalSent != null &&
response.updatedLanguage != null &&
response.updatedLanguage != originalSent.langCode;
if (!hasTokenUpdate && !hasLangUpdate) {
widget.onUpdate();
return response.userFriendlyMessage;
}
// update the tokens to be sent in the message edit
final tokens = List<PangeaToken>.from(widget.requestData.tokens);
if (hasTokenUpdate) {
tokens[widget.requestData.selectedToken] = response.updatedToken!;
}
if (hasLangUpdate) {
originalSent.content.langCode = response.updatedLanguage!;
}
await widget.event.room.pangeaSendTextEvent(
widget.requestData.fullText,
editEventId: widget.event.eventId,
originalSent: originalSent?.content,
originalWritten: widget.event.originalWritten?.content,
tokensSent: PangeaMessageTokens(
tokens: tokens,
),
tokensWritten: widget.event.originalWritten?.tokens != null
? PangeaMessageTokens(
tokens: widget.event.originalWritten!.tokens!,
detections: widget.event.originalWritten?.detections,
)
: null,
choreo: originalSent?.choreo,
);
widget.onUpdate();
return response.userFriendlyMessage;
}
Future<void> _updateLemmaInfo(
PangeaToken token,
LemmaInfoResponse response,
) async {
final construct = token.vocabConstructID;
final currentLemmaInfo = construct.userLemmaInfo;
final updatedLemmaInfo = UserSetLemmaInfo(
meaning: response.meaning,
emojis: response.emoji,
);
if (currentLemmaInfo != updatedLemmaInfo) {
await construct.setUserLemmaInfo(updatedLemmaInfo);
}
}
Future<void> _updatePhoneticTranscription(
PhoneticTranscriptionResponse response,
) async {
final req = PhoneticTranscriptionRequest(
arc: LanguageArc(
l1: PLanguageStore.byLangCode(widget.requestData.wordCardL1) ??
MatrixState.pangeaController.languageController.userL1!,
l2: PLanguageStore.byLangCode(widget.langCode) ??
MatrixState.pangeaController.languageController.userL2!,
),
content: response.content,
);
await PhoneticTranscriptionRepo.set(req, response);
}
@override
Widget build(BuildContext context) {
final selectedToken =
widget.requestData.tokens[widget.requestData.selectedToken];
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5),
child: Dialog(
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20.0),
),
child: SizedBox(
width: 325.0,
child: Column(
spacing: 20.0,
mainAxisSize: MainAxisSize.min,
children: [
// Header with title and close button
Padding(
padding: const EdgeInsets.all(12.0),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
Expanded(
child: Text(
L10n.of(context).tokenInfoFeedbackDialogTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
textAlign: TextAlign.center,
),
),
const SizedBox(
width: 40.0,
height: 40.0,
child: Center(
child: Icon(Icons.flag_outlined),
),
),
],
),
),
// Content
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20.0,
),
child: Column(
spacing: 20.0,
mainAxisSize: MainAxisSize.min,
children: [
// Placeholder for word card
Container(
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context).colorScheme.outline,
),
borderRadius: BorderRadius.circular(8.0),
),
child: Center(
child: WordZoomWidget(
token: selectedToken.text,
construct: selectedToken.vocabConstructID,
langCode: widget.langCode,
),
),
),
// Description text
Text(
L10n.of(context).tokenInfoFeedbackDialogDesc,
textAlign: TextAlign.center,
),
// Feedback text field
TextField(
controller: _feedbackController,
decoration: InputDecoration(
hintText: L10n.of(context).feedbackHint,
),
keyboardType: TextInputType.multiline,
minLines: 1,
maxLines: 5,
),
// Submit button
ValueListenableBuilder<TextEditingValue>(
valueListenable: _feedbackController,
builder: (context, value, _) {
final isNotEmpty = value.text.isNotEmpty;
return ElevatedButton(
onPressed: isNotEmpty
? () async {
final resp = await showFutureLoadingDialog(
context: context,
future: () => _submitFeedback(),
);
if (!resp.isError) {
Navigator.of(context).pop(resp.result!);
}
}
: null,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(L10n.of(context).feedbackButton),
],
),
);
},
),
const SizedBox.shrink(),
],
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,40 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_request.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_response.dart';
import 'package:fluffychat/widgets/matrix.dart';
class TokenInfoFeedbackRepo {
/// Submit token info feedback for processing
///
/// This method sends user feedback about token information to the server
/// for evaluation and potential updates. The feedback is processed
/// and may result in updated token data, lemma information, or phonetics.
static Future<TokenInfoFeedbackResponse> submitFeedback(
TokenInfoFeedbackRequest request,
) async {
final Requests req = Requests(
choreoApiKey: Environment.choreoApiKey,
accessToken: MatrixState.pangeaController.userController.accessToken,
);
final Response res = await req.post(
url: PApiUrls.tokenFeedback,
body: request.toJson(),
);
if (res.statusCode != 200) {
throw Exception(
'Failed to submit token info feedback: ${res.statusCode} ${res.body}',
);
}
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
return TokenInfoFeedbackResponse.fromJson(decodedBody);
}
}

View file

@ -0,0 +1,87 @@
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
class TokenInfoFeedbackRequestData {
final String userId;
final String roomId;
final String fullText;
final String detectedLanguage;
final List<PangeaToken> tokens;
final int selectedToken;
final LemmaInfoResponse? lemmaInfo;
final String? phonetics;
final String wordCardL1;
TokenInfoFeedbackRequestData({
required this.userId,
required this.roomId,
required this.fullText,
required this.detectedLanguage,
required this.tokens,
required this.selectedToken,
this.lemmaInfo,
this.phonetics,
required this.wordCardL1,
});
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TokenInfoFeedbackRequestData &&
runtimeType == other.runtimeType &&
userId == other.userId &&
roomId == other.roomId &&
fullText == other.fullText &&
detectedLanguage == other.detectedLanguage &&
selectedToken == other.selectedToken &&
lemmaInfo == other.lemmaInfo &&
phonetics == other.phonetics &&
wordCardL1 == other.wordCardL1;
@override
int get hashCode =>
userId.hashCode ^
roomId.hashCode ^
fullText.hashCode ^
detectedLanguage.hashCode ^
selectedToken.hashCode ^
lemmaInfo.hashCode ^
phonetics.hashCode ^
wordCardL1.hashCode;
}
class TokenInfoFeedbackRequest {
final TokenInfoFeedbackRequestData data;
final String userFeedback;
TokenInfoFeedbackRequest({
required this.data,
required this.userFeedback,
});
Map<String, dynamic> toJson() {
return {
'user_id': data.userId,
'room_id': data.roomId,
'full_text': data.fullText,
'detected_language': data.detectedLanguage,
'tokens': data.tokens.map((token) => token.toJson()).toList(),
'selected_token': data.selectedToken,
'lemma_info': data.lemmaInfo?.toJson(),
'phonetics': data.phonetics,
'user_feedback': userFeedback,
'word_card_l1': data.wordCardL1,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TokenInfoFeedbackRequest &&
runtimeType == other.runtimeType &&
data == other.data &&
userFeedback == other.userFeedback;
@override
int get hashCode => data.hashCode ^ userFeedback.hashCode;
}

View file

@ -0,0 +1,70 @@
import 'package:fluffychat/pangea/events/models/content_feedback.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_response.dart';
class TokenInfoFeedbackResponse implements JsonSerializable {
final String userFriendlyMessage;
final PangeaToken? updatedToken;
final LemmaInfoResponse? updatedLemmaInfo;
final PhoneticTranscriptionResponse? updatedPhonetics;
final String? updatedLanguage;
TokenInfoFeedbackResponse({
required this.userFriendlyMessage,
this.updatedToken,
this.updatedLemmaInfo,
this.updatedPhonetics,
this.updatedLanguage,
});
factory TokenInfoFeedbackResponse.fromJson(Map<String, dynamic> json) {
return TokenInfoFeedbackResponse(
userFriendlyMessage: json['user_friendly_message'] as String,
updatedToken: json['updated_token'] != null
? PangeaToken.fromJson(json['updated_token'] as Map<String, dynamic>)
: null,
updatedLemmaInfo: json['updated_lemma_info'] != null
? LemmaInfoResponse.fromJson(
json['updated_lemma_info'] as Map<String, dynamic>,
)
: null,
updatedPhonetics: json['updated_phonetics'] != null
? PhoneticTranscriptionResponse.fromJson(
json['updated_phonetics'] as Map<String, dynamic>,
)
: null,
updatedLanguage: json['updated_language'] as String?,
);
}
@override
Map<String, dynamic> toJson() {
return {
'user_friendly_message': userFriendlyMessage,
'updated_token': updatedToken?.toJson(),
'updated_lemma_info': updatedLemmaInfo?.toJson(),
'updated_phonetics': updatedPhonetics?.toJson(),
'updated_language': updatedLanguage,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is TokenInfoFeedbackResponse &&
runtimeType == other.runtimeType &&
userFriendlyMessage == other.userFriendlyMessage &&
updatedToken == other.updatedToken &&
updatedLemmaInfo == other.updatedLemmaInfo &&
updatedPhonetics == other.updatedPhonetics &&
updatedLanguage == other.updatedLanguage;
@override
int get hashCode =>
userFriendlyMessage.hashCode ^
updatedToken.hashCode ^
updatedLemmaInfo.hashCode ^
updatedPhonetics.hashCode ^
updatedLanguage.hashCode;
}

View file

@ -8,6 +8,7 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_request.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_unsubscribed_card.dart';
@ -71,6 +72,16 @@ class ReadingAssistanceContentState extends State<ReadingAssistanceContent> {
// return MessageModeLockedCard(controller: widget.overlayController);
// }
final tokens =
widget.overlayController.pangeaMessageEvent.originalSent?.tokens;
final selectedToken = widget.overlayController.selectedToken;
final selectedTokenIndex = selectedToken != null
? tokens?.indexWhere(
(t) => t.text == selectedToken.text,
) ??
-1
: -1;
switch (widget.overlayController.toolbarMode) {
case MessageMode.messageTranslation:
// return MessageTranslationCard(
@ -125,6 +136,21 @@ class ReadingAssistanceContentState extends State<ReadingAssistanceContent> {
.overlayController.pangeaMessageEvent.messageDisplayLangCode,
onDismissNewWordOverlay: () =>
widget.overlayController.setState(() {}),
requestData: selectedTokenIndex >= 0
? TokenInfoFeedbackRequestData(
userId: Matrix.of(context).client.userID!,
roomId: widget.overlayController.event.room.id,
fullText: widget
.overlayController.pangeaMessageEvent.messageDisplayText,
detectedLanguage: widget.overlayController.pangeaMessageEvent
.messageDisplayLangCode,
tokens: tokens ?? [],
selectedToken: selectedTokenIndex,
wordCardL1: MatrixState.pangeaController.languageController
.activeL1Code()!,
)
: null,
pangeaMessageEvent: widget.overlayController.pangeaMessageEvent,
);
}
}

View file

@ -1,19 +1,19 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart';
import 'package:fluffychat/pangea/lemmas/lemma_reaction_picker.dart';
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_button.dart';
import 'package:fluffychat/pangea/token_info_feedback/token_info_feedback_request.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';
@ -24,21 +24,26 @@ class WordZoomWidget extends StatelessWidget {
final ConstructIdentifier construct;
final String langCode;
final VoidCallback onClose;
final VoidCallback? onClose;
final bool wordIsNew;
final VoidCallback? onDismissNewWordOverlay;
final Event? event;
final TokenInfoFeedbackRequestData? requestData;
final PangeaMessageEvent? pangeaMessageEvent;
const WordZoomWidget({
super.key,
required this.token,
required this.construct,
required this.langCode,
required this.onClose,
this.onClose,
this.wordIsNew = false,
this.onDismissNewWordOverlay,
this.event,
this.requestData,
this.pangeaMessageEvent,
});
String get transformTargetId => "word-zoom-card-${token.uniqueKey}";
@ -69,20 +74,25 @@ class WordZoomWidget extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SizedBox(
width: 24.0,
height: 24.0,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onClose,
child: const Icon(
Icons.close,
size: 16.0,
onClose != null
? SizedBox(
width: 24.0,
height: 24.0,
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onClose,
child: const Icon(
Icons.close,
size: 16.0,
),
),
),
)
: const SizedBox(
width: 24.0,
height: 24.0,
),
),
),
),
Flexible(
child: Text(
token.content,
@ -98,12 +108,22 @@ class WordZoomWidget extends StatelessWidget {
),
),
),
ConstructXpWidget(
id: construct,
onTap: () => context.go(
"/rooms/analytics/${ConstructTypeEnum.vocab.string}/${construct.string}",
),
),
requestData != null && pangeaMessageEvent != null
? TokenInfoFeedbackButton(
requestData: requestData!,
langCode: langCode,
event: pangeaMessageEvent!,
onUpdate: () {
// close the zoom when updating
if (onClose != null) {
onClose!();
}
},
)
: const SizedBox(
width: 24.0,
height: 24.0,
),
],
),
LemmaMeaningBuilder(