Reading assistance (#2175)

* still in draft

* feat(reading_assistance): whole message activity oriented

* chore: fix .env file path

* feat: animate selected toolbar into middle of screen

* chore: initial work for message bubble size animation

* refactor(reading_assistance): hooking up the choice interactions and polishing UI

* chore: animate in content and buttons

* formatting

* position reading content relative to selected token

* working on limiting choices

* chore: fix positioning of toolbar animation

* chore: simplify positioning logic

* chore: animate in button height

* getting there

* rough draft with restricted activity number is complete

---------

Co-authored-by: ggurdin <ggurdin@gmail.com>
This commit is contained in:
wcjord 2025-03-24 15:20:07 -04:00 committed by GitHub
parent f8feab5eea
commit 379e4a8db9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
105 changed files with 5009 additions and 2906 deletions

View file

@ -4507,7 +4507,7 @@
},
"botModeValidation": "Please select a chat mode",
"clickBestOption": "Choose the best options to translate your message!",
"completeActivitiesToUnlock": "Complete word activities to unlock audio and translation!",
"completeActivitiesToUnlock": "Complete one of the activities (emoji, meaning, listening OR grammar) to unlock the translation!",
"botSettingsSubtitle": "Invite bot to moderate chat activity",
"invitePeople": "Invite users",
"noCapacityLimit": "No capacity limit",
@ -4590,7 +4590,7 @@
"pleaseEnterEmail": "Please enter a valid email address.",
"pleaseSelectALanguage": "Please select a language",
"myBaseLanguage": "My base language",
"clickWordsInstructions": "Click on a word or the buttons below to learn more",
"clickWordsInstructions": "🕵️ Click any word for details. 🧐",
"chooseBestDefinition": "What does this word mean?",
"meaningSectionHeader": "Meaning:",
"formSectionHeader": "Forms used in chats:",
@ -4671,7 +4671,7 @@
}
}
},
"lemmaMeaningInstructionsBody": "Above is the meaning of the lemma. Double-click to edit.",
"chooseLemmaMeaningInstructionsBody": "Match the meanings below with the underlined words in the message.",
"doubleClickToEdit": "Double-click to edit.",
"removeFeature": "Remove {feature}",
"@removeFeature": {
@ -4711,7 +4711,6 @@
"video": "Video",
"nan": "Not applicable",
"activityPlannerOverviewInstructionsBody": "Choose a topic, mode, learning objective and generate an activity for the chat!",
"completeActivitiesToUnlock": "Complete the highlighted word activities to unlock",
"myBookmarkedActivities": "My Bookmarked Activities",
"noBookmarkedActivities": "No bookmarked activities",
"activityTitle": "Activity Title",
@ -4793,7 +4792,7 @@
"downloadGboard": "Download Gboard",
"autocorrectNotAvailable": "Unfortunately your platform is not currently supported for this feature. Stay tuned for further development!",
"pleaseUpdateApp": "Please update the app to continue.",
"chooseEmojiInstructionsBody": "Pick an emoji for the word! There's no wrong answer and you can switch anytime! 😀",
"chooseEmojiInstructionsBody": "Match emojis with the words they best represent. Don't worry! No points off for disagreeing. 😅",
"pickAnEmojiFor": "Pick an emoji for ${lemma}",
"@pickAnEmojiFor": {
"type": "String",
@ -4808,11 +4807,14 @@
"knockSpaceSuccess": "You have requested to join this space! An admin will respond to your request when they receive it 😀",
"joinByCode": "Join by code",
"createASpace": "Create a space",
"chooseWordAudioInstructionsBody": "Listen to the full message then match the word audios to the right blanks!",
"chooseMorphsInstructionsBody": "Match the grammar tags with the words in the message. Click and hold an option for a hint!",
"inviteAndLaunch": "Launch and invite",
"createOwnChat": "Create your own chat",
"pleaseEnterInt": "Please enter a number",
"home": "Home",
"join": "Join",
"readingAssistanceOverviewBody": "Click the buttons below for mini-games on visualizing vocab, practice listening, meaning, and grammar concepts. Click any word for details.",
"learnByTexting": "Learn by texting",
"levelSummaryTrigger": "View summary",
"levelSummaryPopupTitle": "Level {level} Summary",

View file

@ -5144,7 +5144,6 @@
"other": "Otros",
"botModeValidation": "Seleccione un modo de chat",
"clickBestOption": "Elija las mejores opciones para traducir su mensaje",
"completeActivitiesToUnlock": "¡Completa actividades de palabras para desbloquear audio y traducción!",
"botSettingsSubtitle": "Invitar a un bot a moderar la actividad del chat",
"invitePeople": "Invitar a usuarios",
"noCapacityLimit": "Sin límite de capacidad",
@ -5334,7 +5333,6 @@
}
}
},
"lemmaMeaningInstructionsBody": "Arriba está el significado del lema. Haz doble clic para editar.",
"doubleClickToEdit": "Haz doble clic para editar.",
"removeFeature": "Eliminar {feature}",
"@removeFeature": {
@ -5374,7 +5372,6 @@
"video": "Video",
"nan": "No aplicable",
"activityPlannerOverviewInstructionsBody": "¡Elige un tema, modo, objetivo de aprendizaje y genera una actividad para el chat!",
"completeActivitiesToUnlock": "Completa las actividades de palabras resaltadas para desbloquear",
"myBookmarkedActivities": "Mis Actividades Marcadas",
"noBookmarkedActivities": "No hay actividades marcadas",
"activityTitle": "Título de la Actividad",
@ -5453,4 +5450,4 @@
},
"downloadGboard": "Descargar Gboard",
"autocorrectNotAvailable": "Desafortunadamente, tu plataforma no es compatible actualmente con esta función. ¡Mantente atento a futuros desarrollos!"
}
}

View file

@ -3304,7 +3304,6 @@
},
"botModeValidation": "Vui lòng chọn một chế độ trò chuyện",
"clickBestOption": "Chọn phương án tốt nhất để dịch tin nhắn của bạn!",
"completeActivitiesToUnlock": "Hoàn thành các hoạt động từ vựng được đánh dấu để mở khóa",
"botSettingsSubtitle": "Mời bot kiểm duyệt hoặc khởi tạo hoạt động trò chuyện",
"invitePeople": "Mời người dùng",
"invitePeopleChatSubtitle": "Mời người dùng hoặc quản trị viên đến cuộc trò chuyện này",
@ -3527,7 +3526,6 @@
}
}
},
"lemmaMeaningInstructionsBody": "Ở trên là nghĩa của mục từ. Nhấp đúp để chỉnh sửa.",
"doubleClickToEdit": "Nhấp đúp để chỉnh sửa.",
"removeFeature": "Xóa {feature}",
"@removeFeature": {

View file

@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
abstract class AppConfig {
// #Pangea
@ -23,16 +21,14 @@ abstract class AppConfig {
static const double messageFontSize = 16.0;
static const bool allowOtherHomeservers = true;
static const bool enableRegistration = true;
static const double toolbarMaxHeight = 175.0;
static const double toolbarMinHeight = 140.0;
static const double toolbarMaxHeight = 250.0;
static const double toolbarMinHeight = 200.0;
static const double toolbarMinWidth = 350.0;
static const double toolbarButtonsColumnWidth = 50.0;
static const double toolbarButtonAndProgressColumnHeight = 200.0;
static const double defaultHeaderHeight = 56.0;
static const double readingAssistanceInputBarHeight = 150;
static const double readingAssistanceInputBarHeight = 170;
static const double toolbarSpacing = 8.0;
static const double toolbarIconSize = 24.0;
static const double toolbarButtonsColumnHeight = 240;
static TextStyle messageTextStyle(
Event? event,
Color textColor,

View file

@ -1,6 +1,12 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.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/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/mxc_image.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';
@ -8,13 +14,6 @@ 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/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/mxc_image.dart';
import '../../../utils/url_launcher.dart';
class HtmlMessage extends StatelessWidget {
@ -270,6 +269,7 @@ class HtmlMessage extends StatelessWidget {
) ??
false;
// @ggurdin: probably changing this, not sure when it shows up
final didMeaningActivity = token?.didActivitySuccessfully(
ActivityTypeEnum.wordMeaning,
) ??

View file

@ -1,21 +1,21 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/video_player.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/pangea_rich_text.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.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_token_text.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_toolbar_selection_area.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:matrix/matrix.dart';
import '../../../config/app_config.dart';
import '../../../utils/platform_infos.dart';
import '../../../utils/url_launcher.dart';
@ -42,6 +42,7 @@ class MessageContent extends StatelessWidget {
final ChatController controller;
final Event? nextEvent;
final Event? prevEvent;
final bool isTransitionAnimation;
// Pangea#
final Timeline timeline;
@ -58,6 +59,7 @@ class MessageContent extends StatelessWidget {
required this.controller,
this.nextEvent,
this.prevEvent,
this.isTransitionAnimation = false,
// Pangea#
required this.linkColor,
required this.borderRadius,
@ -377,6 +379,18 @@ class MessageContent extends StatelessWidget {
style: messageTextStyle,
onClick: onClick,
isSelected: overlayController != null ? isSelected : null,
messageMode: overlayController?.toolbarMode,
isHighlighted: (PangeaToken token) =>
overlayController!.toolbarMode.associatedActivityType !=
null &&
overlayController?.messageAnalyticsEntry?.hasActivity(
overlayController!
.toolbarMode.associatedActivityType!,
token,
) ==
true,
overlayController: overlayController,
isTransitionAnimation: isTransitionAnimation,
);
}

View file

@ -36,6 +36,8 @@ class AnalyticsPopupWrapperState extends State<AnalyticsPopupWrapper> {
ConstructIdentifier? localConstructZoom;
ConstructTypeEnum localView = ConstructTypeEnum.vocab;
// @ggurdin
//TODO: make language-specific
MorphFeaturesAndTags morphs = defaultMorphMapping;
List<MorphFeature> features = defaultMorphMapping.displayFeatures;

View file

@ -85,7 +85,7 @@ class AnalyticsDetailsViewContent extends StatelessWidget {
construct: construct,
category: LearningSkillsEnum.hearing,
tooltip: L10n.of(context).listeningExercisesTooltip,
icon: Symbols.hearing,
icon: Icons.volume_up,
),
// Reading exercise section
LemmaUsageDots(

View file

@ -1,8 +1,4 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
@ -12,9 +8,11 @@ import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:fluffychat/pangea/user/client_extension.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class MorphAnalyticsListView extends StatelessWidget {
final void Function(ConstructIdentifier) onConstructZoom;
@ -75,20 +73,8 @@ class MorphFeatureBox extends StatelessWidget {
required this.onConstructZoom,
});
String _categoryCopy(
String category,
BuildContext context,
) {
if (category.toLowerCase() == "other") {
return L10n.of(context).other;
}
return ConstructTypeEnum.morph.getDisplayCopy(
category,
context,
) ??
category;
}
MorphFeaturesEnum get feature =>
MorphFeaturesEnumExtension.fromString(morphFeature);
@override
Widget build(BuildContext context) {
@ -98,7 +84,7 @@ class MorphFeatureBox extends StatelessWidget {
padding: const EdgeInsets.all(16.0),
margin: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16.0),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
border: Border.all(
color: Theme.of(context).brightness == Brightness.dark
? AppConfig.primaryColorLight
@ -116,10 +102,10 @@ class MorphFeatureBox extends StatelessWidget {
SizedBox(
height: 30.0,
width: 30.0,
child: MorphIcon(morphFeature: morphFeature, morphTag: null),
child: MorphIcon(morphFeature: feature, morphTag: null),
),
Text(
_categoryCopy(morphFeature, context),
feature.getDisplayCopy(context),
style: theme.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
@ -193,13 +179,16 @@ class MorphTagChip extends StatelessWidget {
this.onTap,
});
MorphFeaturesEnum get feature =>
MorphFeaturesEnumExtension.fromString(morphFeature);
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final unlocked = constructAnalytics.points > 10;
return InkWell(
borderRadius: BorderRadius.circular(32.0),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
onTap: onTap,
child: Opacity(
opacity: unlocked ? 1.0 : 0.3,
@ -231,7 +220,7 @@ class MorphTagChip extends StatelessWidget {
height: 28.0,
child: unlocked || Matrix.of(context).client.isSupportAccount
? MorphIcon(
morphFeature: morphFeature,
morphFeature: feature,
morphTag: morphTag,
)
: const Icon(

View file

@ -1,17 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup_content.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart';
import 'package:fluffychat/pangea/morphs/morph_feature_display.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart';
import 'package:fluffychat/pangea/morphs/morph_tag_display.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class MorphDetailsView extends StatelessWidget {
final ConstructIdentifier constructId;
@ -37,70 +35,91 @@ class MorphDetailsView extends StatelessWidget {
: _construct.lemmaCategory.darkColor(context);
return AnalyticsDetailsViewContent(
title:
MorphFeatureDisplay(morphFeature: _morphFeature, morphTag: _morphTag),
subtitle:
MorphTagDisplay(morphFeature: _morphFeature, textColor: textColor),
subtitle: MorphFeatureDisplay(morphFeature: _morphFeature),
title: MorphTagDisplay(
morphFeature: MorphFeaturesEnumExtension.fromString(_morphFeature),
morphTag: _morphTag,
textColor: textColor,
),
headerContent: Padding(
padding: const EdgeInsets.all(25.0),
child: Align(
alignment: Alignment.topLeft,
child: FutureBuilder(
future: _getDefinition(context),
builder: (
BuildContext context,
AsyncSnapshot<String?> snapshot,
) {
if (snapshot.hasData) {
return MorphMeaningWidget(
feature: _morphFeature,
tag: _morphTag,
style: Theme.of(context).textTheme.bodyLarge,
leading: TextSpan(
text: L10n.of(context).meaningSectionHeader,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
);
} else if (snapshot.hasError) {
return Wrap(
children: [
Text(
L10n.of(context).meaningSectionHeader,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(
width: 10,
),
Text(
L10n.of(context).meaningNotFound,
style: Theme.of(context).textTheme.bodyLarge,
),
],
);
} else {
return Wrap(
children: [
Text(
L10n.of(context).meaningSectionHeader,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(
width: 10,
),
const TextLoadingShimmer(width: 100),
],
);
}
},
),
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
MorphMeaningWidget(
feature: _morphFeature,
tag: _morphTag,
style: Theme.of(context).textTheme.bodyLarge,
// leading: TextSpan(
// text: L10n.of(context).meaningSectionHeader,
// style: const TextStyle(
// fontWeight: FontWeight.bold,
// ),
// ),
),
],
),
),
// headerContent: Padding(
// padding: const EdgeInsets.all(25.0),
// child: Align(
// alignment: Alignment.topLeft,
// child: FutureBuilder(
// future: _getDefinition(context),
// builder: (
// BuildContext context,
// AsyncSnapshot<String?> snapshot,
// ) {
// if (snapshot.hasData) {
// return MorphMeaningWidget(
// feature: _morphFeature,
// tag: _morphTag,
// style: Theme.of(context).textTheme.bodyLarge,
// leading: TextSpan(
// text: L10n.of(context).meaningSectionHeader,
// style: const TextStyle(
// fontWeight: FontWeight.bold,
// ),
// ),
// );
// } else if (snapshot.hasError) {
// return Wrap(
// children: [
// Text(
// L10n.of(context).meaningSectionHeader,
// style: Theme.of(context).textTheme.bodyLarge?.copyWith(
// fontWeight: FontWeight.bold,
// ),
// ),
// const SizedBox(
// width: 10,
// ),
// Text(
// L10n.of(context).meaningNotFound,
// style: Theme.of(context).textTheme.bodyLarge,
// ),
// ],
// );
// } else {
// return Wrap(
// children: [
// Text(
// L10n.of(context).meaningSectionHeader,
// style: Theme.of(context).textTheme.bodyLarge?.copyWith(
// fontWeight: FontWeight.bold,
// ),
// ),
// const SizedBox(
// width: 10,
// ),
// const TextLoadingShimmer(width: 100),
// ],
// );
// }
// },
// ),
// ),
// ),
xpIcon: ConstructXpWidget(id: constructId),
constructId: constructId,
);

View file

@ -1,20 +1,16 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup_content.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/lemmas/lemma_emoji_row.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
/// Displays information about selected lemma, and its usage
class VocabDetailsView extends StatelessWidget {
@ -27,41 +23,6 @@ class VocabDetailsView extends StatelessWidget {
ConstructUses get _construct => constructId.constructUses;
String? get _emoji => PangeaToken(
text: PangeaTokenText(
offset: 0,
content: _construct.lemma,
length: _construct.lemma.length,
),
lemma: Lemma(
text: _construct.lemma,
saveVocab: false,
form: _construct.lemma,
),
pos: _construct.category,
morph: {},
).getEmoji();
/// Get string representing forms of the given lemma that have been used
String? get _formString {
// Get possible forms of lemma
final constructs = MatrixState
.pangeaController.getAnalytics.constructListModel
.getConstructUsesByLemma(_construct.lemma);
final forms = constructs
.map((e) => e.uses)
.expand((element) => element)
.where((use) => use.useType.pointValue > 0)
.map((e) => e.form?.toLowerCase())
.toSet()
.whereType<String>()
.toList();
if (forms.isEmpty) return null;
return forms.join(", ");
}
/// Get the language code for the current lemma
String? get _userL2 =>
MatrixState.pangeaController.languageController.userL2?.langCode;
@ -75,101 +36,102 @@ class VocabDetailsView extends StatelessWidget {
return AnalyticsDetailsViewContent(
title: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
SizedBox(
width: 42,
child: _emoji == null
? Tooltip(
message: L10n.of(context).noEmojiSelectedTooltip,
child: Icon(
Icons.add_reaction_outlined,
size: 24,
color: textColor.withValues(alpha: 0.7),
),
)
: Text(_emoji!),
),
const SizedBox(width: 10.0),
Text(
_construct.lemma,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(width: 10.0),
WordAudioButton(
text: _construct.lemma,
size: 24,
),
const SizedBox(width: 4.0),
Text(
_construct.lemma,
style: Theme.of(context).textTheme.headlineLarge?.copyWith(
color: textColor,
),
),
],
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Tooltip(
message: L10n.of(context).grammarCopyPOS,
child: Icon(
Symbols.toys_and_games,
size: 23,
color: textColor.withValues(alpha: 0.7),
),
MorphIcon(
morphFeature: MorphFeaturesEnum.Pos,
morphTag: _construct.category,
),
const SizedBox(width: 10.0),
const SizedBox(width: 4.0),
Text(
getGrammarCopy(
category: "pos",
lemma: _construct.category,
context: context,
) ??
_construct.category,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
MorphFeaturesEnum.Pos.getDisplayCopy(context),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: textColor,
),
),
],
),
headerContent: Padding(
padding: const EdgeInsets.all(20),
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
spacing: 8.0,
children: [
Padding(
padding: const EdgeInsets.all(5.0),
child: Align(
alignment: Alignment.topLeft,
child: _userL2 == null
? Text(L10n.of(context).meaningNotFound)
: LemmaMeaningWidget(
constructUse: _construct,
langCode: _userL2!,
controller: null,
token: null,
style: Theme.of(context).textTheme.bodyLarge,
leading: TextSpan(
text: L10n.of(context).meaningSectionHeader,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
LemmaEmojiRow(
cId: constructId,
onTap: () => {},
removeCallback: null,
),
],
),
Align(
alignment: Alignment.topLeft,
child: _userL2 == null
? Text(L10n.of(context).meaningNotFound)
: LemmaMeaningWidget(
constructUse: _construct,
langCode: _userL2!,
controller: null,
token: null,
style: Theme.of(context).textTheme.bodyLarge,
leading: TextSpan(
text: L10n.of(context).meaningSectionHeader,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(5.0),
child: Align(
alignment: Alignment.topLeft,
child: Row(
children: [
Text(
L10n.of(context).formSectionHeader,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
Text(
" ${_formString ?? L10n.of(context).formsNotFound}",
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
Align(
alignment: Alignment.topLeft,
child: Wrap(
alignment: WrapAlignment.start,
runAlignment: WrapAlignment.start,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Text(
L10n.of(context).formSectionHeader,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
...MatrixState
.pangeaController.getAnalytics.constructListModel
.getConstructUsesByLemma(_construct.lemma)
.map((e) => e.uses)
.expand((element) => element)
.map((e) => e.form?.toLowerCase())
.toSet()
.whereType<String>()
.map(
(form) => WordTextWithAudioButton(
text: form,
textSize: Theme.of(context)
.textTheme
.bodyMedium
?.fontSize ??
16,
),
),
],
),
),
],

View file

@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/toolbar/utils/shrinkable_text.dart';
import 'package:flutter/material.dart';
class VocabAnalyticsListTile extends StatefulWidget {
const VocabAnalyticsListTile({
@ -53,10 +52,10 @@ class VocabAnalyticsListTileState extends State<VocabAnalyticsListTile> {
height: (maxWidth - padding * 2) * 0.6,
child: Opacity(
opacity:
widget.constructUse.id.userSetEmoji == null ? 0.2 : 1,
child: widget.constructUse.id.userSetEmoji != null
widget.constructUse.id.userSetEmoji.isEmpty ? 0.2 : 1,
child: widget.constructUse.id.userSetEmoji.isNotEmpty
? Text(
widget.constructUse.id.userSetEmoji!,
widget.constructUse.id.userSetEmoji.first,
style: const TextStyle(
fontSize: 22,
),

View file

@ -5,7 +5,7 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_categories_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart';
enum ConstructTypeEnum {

View file

@ -1,10 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_downloads/analytics_summary_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
enum ConstructUseTypeEnum {
/// produced in chat by user, igc was run, and we've judged it to be a correct use

View file

@ -1,18 +1,11 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/common/constants/local.key.dart';
import 'package:fluffychat/pangea/common/controllers/base_controller.dart';
@ -23,6 +16,11 @@ import 'package:fluffychat/pangea/constructs/construct_repo.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart';
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics
class GetAnalyticsController extends BaseController {
@ -42,10 +40,6 @@ class GetAnalyticsController extends BaseController {
GetAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
perMessage = MessageAnalyticsController(
this,
);
}
LanguageModel? get _l2 => _pangeaController.languageController.userL2;

View file

@ -1,265 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.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_model.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
/// Picks which tokens to do activities on and what types of activities to do
/// Caches result so that we don't have to recompute it
/// Most importantly, we can't do this in the state of a message widget because the state is disposed of and recreated
/// If we decided that the first token should have a hidden word listening, we need to remember that
/// Otherwise, the user might leave the chat, return, and see a different word hidden
class TargetTokensAndActivityType {
final List<PangeaToken> tokens;
final ActivityTypeEnum activityType;
TargetTokensAndActivityType({
required this.tokens,
required this.activityType,
});
bool matchesActivity(PracticeActivityModel activity) {
// check if the existing activity has the same type as the target
if (activity.activityType != activityType) {
return false;
}
// This is kind of complicated
// if it's causing problems,
// maybe we just verify that the target span of the activity is the same as the target span of the target?
final List<ConstructIdentifier> relevantConstructs = tokens
.map((t) => t.constructs)
.expand((e) => e)
.map((c) => c.id)
.where(activityType.constructFilter)
.toList();
final List<ConstructIdentifier>? otherRelevantConstructs = activity
.targetTokens
?.map((t) => t.constructs)
.expand((e) => e)
.map((c) => c.id)
.where(activityType.constructFilter)
.toList();
return listEquals(otherRelevantConstructs, relevantConstructs);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TargetTokensAndActivityType &&
listEquals(other.tokens, tokens) &&
other.activityType == activityType;
}
@override
int get hashCode => tokens.hashCode ^ activityType.hashCode;
}
class MessageAnalyticsEntry {
final DateTime createdAt = DateTime.now();
late final List<PangeaToken> _tokens;
final List<TargetTokensAndActivityType> _activityQueue = [];
final int _maxQueueLength = 3;
MessageAnalyticsEntry({
required List<PangeaToken> tokens,
required bool includeHiddenWordActivities,
required PangeaMessageEvent pangeaMessageEvent,
}) {
_tokens = tokens;
setActivityQueue();
}
void _pushQueue(TargetTokensAndActivityType entry) {
if (nextActivity?.activityType == ActivityTypeEnum.hiddenWordListening) {
if (entry.activityType == ActivityTypeEnum.hiddenWordListening) {
_activityQueue[0] = entry;
} else {
_activityQueue.insert(1, entry);
}
} else {
_activityQueue.insert(0, entry);
}
if (_activityQueue.length > _maxQueueLength) {
_activityQueue.removeRange(
_maxQueueLength,
_activityQueue.length,
);
}
}
void _popQueue() {
if (_activityQueue.isNotEmpty) _activityQueue.removeAt(0);
}
void _filterQueue(ActivityTypeEnum activityType) {
_activityQueue.removeWhere((a) => a.activityType == activityType);
}
void _clearQueue() {
_activityQueue.clear();
}
TargetTokensAndActivityType? get nextActivity =>
_activityQueue.isNotEmpty ? _activityQueue.first : null;
bool get hasHiddenWordActivity =>
nextActivity?.activityType.hiddenType ?? false;
bool get hasMessageMeaningActivity => _activityQueue
.any((a) => a.activityType == ActivityTypeEnum.messageMeaning);
int get numActivities => _activityQueue.length;
// /// If there are more than 4 tokens that can be heard, we don't want to do word focus listening
// /// Otherwise, we don't have enough distractors
// bool get canDoWordFocusListening =>
// _tokens.where((t) => t.canBeHeard).length > 4;
/// On initialization, we pick which tokens to do activities on and what types of activities to do
void setActivityQueue() {
final List<TargetTokensAndActivityType> queue = [];
// if applicable, add a hidden word activity to the front of the queue
final hiddenWordActivity = getHiddenWordActivity(queue.length);
if (hiddenWordActivity != null) {
_pushQueue(hiddenWordActivity);
}
}
/// Add a message meaning activity to the front of the queue
/// And limits to _maxQueueLength activities
void addMessageMeaningActivity() {
final entry = TargetTokensAndActivityType(
tokens: _tokens,
activityType: ActivityTypeEnum.messageMeaning,
);
_pushQueue(entry);
}
/// Returns a hidden word activity if there is a sequence of tokens that have hiddenWordListening in their eligibleActivityTypes
TargetTokensAndActivityType? getHiddenWordActivity(int numOtherActivities) {
return null;
// don't do hidden word listening on own messages
// if (!_includeHiddenWordActivities) {
// return null;
// }
// // we will only do hidden word listening 30% of the time
// // if there are no other activities to do, we will always do hidden word listening
// if (Random().nextDouble() < 0.7) {
// // if (numOtherActivities >= _maxQueueLength && Random().nextDouble() < 0.5) {
// return null;
// }
// // We will find the longest sequence of tokens that have hiddenWordListening in their eligibleActivityTypes
// final List<List<PangeaToken>> sequences = [];
// List<PangeaToken> currentSequence = [];
// for (final token in _tokens) {
// if (_pangeaMessageEvent.shouldDoActivity(
// token: token,
// a: ActivityTypeEnum.hiddenWordListening,
// feature: null,
// tag: null,
// )) {
// currentSequence.add(token);
// } else {
// if (currentSequence.isNotEmpty) {
// sequences.add(currentSequence);
// currentSequence = [];
// }
// }
// }
// if (sequences.isEmpty) {
// return null;
// }
// final longestSequence = sequences.reduce(
// (a, b) => a.length > b.length ? a : b,
// );
// // Truncate the sequence to a maximum of 2 words
// final truncatedSequence = longestSequence.take(2).toList();
// return TargetTokensAndActivityType(
// tokens: truncatedSequence,
// activityType: ActivityTypeEnum.hiddenWordListening,
// );
}
void onActivityComplete() => _popQueue();
void exitPracticeFlow() => _clearQueue();
void revealAllTokens() => _filterQueue(ActivityTypeEnum.hiddenWordListening);
bool isTokenInHiddenWordActivity(PangeaToken token) => _activityQueue.any(
(activity) =>
activity.tokens.contains(token) && activity.activityType.hiddenType,
);
}
/// computes TokenWithXP for given a pangeaMessageEvent and caches the result, according to the full text of the message
/// listens for analytics updates and updates the cache accordingly
class MessageAnalyticsController {
final GetAnalyticsController getAnalytics;
final Map<String, MessageAnalyticsEntry> _cache = {};
MessageAnalyticsController(this.getAnalytics);
void dispose() {
_cache.clear();
}
// if over 50, remove oldest 5 entries by createdAt
void clean() {
if (_cache.length > 50) {
final sortedEntries = _cache.entries.toList()
..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
for (var i = 0; i < 5; i++) {
_cache.remove(sortedEntries[i].key);
}
}
}
String _key(List<PangeaToken> tokens) => PangeaToken.reconstructText(tokens);
MessageAnalyticsEntry? get(
List<PangeaToken> tokens,
PangeaMessageEvent pangeaMessageEvent,
) {
final String key = _key(tokens);
if (_cache.containsKey(key)) {
return _cache[key];
}
final bool includeHiddenWordActivities = !pangeaMessageEvent.ownMessage &&
pangeaMessageEvent.messageDisplayRepresentation?.tokens != null &&
pangeaMessageEvent.messageDisplayLangIsL2 &&
!pangeaMessageEvent.event.isRichMessage;
_cache[key] = MessageAnalyticsEntry(
tokens: tokens,
includeHiddenWordActivities: includeHiddenWordActivities,
pangeaMessageEvent: pangeaMessageEvent,
);
clean();
return _cache[key];
}
}

View file

@ -1,12 +1,12 @@
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/practice_activities/message_analytics_controller.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_token_text.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.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/toolbar/widgets/message_token_text.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../../utils/matrix_sdk_extensions/matrix_locals.dart';
class ChatListItemSubtitle extends StatelessWidget {
@ -88,7 +88,7 @@ class ChatListItemSubtitle extends StatelessWidget {
final tokens = messageEventAndTokens.tokens;
final analyticsEntry = tokens != null
? MatrixState.pangeaController.getAnalytics.perMessage.get(
? MessageAnalyticsController.get(
tokens,
pangeaMessageEvent,
)
@ -96,7 +96,7 @@ class ChatListItemSubtitle extends StatelessWidget {
return MessageTextWidget(
pangeaMessageEvent: pangeaMessageEvent,
style: style,
existingStyle: style,
messageAnalyticsEntry: analyticsEntry,
isSelected: null,
onClick: null,

View file

@ -151,6 +151,7 @@ class ModelKey {
static const String latestBuildNumber = "latest_build_number";
static const String mandatoryUpdate = "mandatory_update";
static const String emoji = "emoji";
static const String emojiList = "emoji_list";
static const String analytics = "analytics";
static const String level = "level";

View file

@ -38,6 +38,12 @@ class PangeaAnyState {
}
void disposeByWidgetKey(String transformTargetId) {
final index =
entries.indexWhere((element) => element.key == transformTargetId);
if (index != -1) {
entries[index].entry.remove();
entries.removeAt(index);
}
_layerLinkAndKeys.remove(transformTargetId);
}

View file

@ -7,7 +7,6 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:http/http.dart' as http;
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/utils/platform_infos.dart';
class PangeaWarningError implements Exception {
@ -22,18 +21,18 @@ class ErrorHandler {
ErrorHandler();
static Future<void> initialize() async {
await SentryFlutter.init(
(options) {
options.dsn = Environment.sentryDsn;
options.tracesSampleRate = 0.1;
options.debug = kDebugMode;
options.environment = kDebugMode
? "debug"
: Environment.isStaging
? "staging"
: "productionC";
},
);
// await SentryFlutter.init(
// (options) {
// options.dsn = Environment.sentryDsn;
// options.tracesSampleRate = 0.1;
// options.debug = kDebugMode;
// options.environment = kDebugMode
// ? "debug"
// : Environment.isStaging
// ? "staging"
// : "productionC";
// },
// );
// Error handling
FlutterError.onError = (FlutterErrorDetails details) async {

View file

@ -101,6 +101,7 @@ class OverlayUtil {
bool closePrevOverlay = true,
String? overlayKey,
bool isScrollable = true,
bool addBorder = true,
}) {
try {
final LayerLinkAndKey layerLinkAndKey =
@ -119,17 +120,21 @@ class OverlayUtil {
final Offset transformTargetOffset =
(targetRenderBox).localToGlobal(Offset.zero);
final Size transformTargetSize = targetRenderBox.size;
final horizontalMidpoint =
transformTargetOffset.dx + (transformTargetSize.width / 2);
final columnWidth = FluffyThemes.isColumnMode(context)
? FluffyThemes.columnWidth + FluffyThemes.navRailWidth
: 0;
final horizontalMidpoint = (transformTargetOffset.dx - columnWidth) +
(transformTargetSize.width / 2);
final verticalMidpoint =
transformTargetOffset.dy + (transformTargetSize.height / 2);
debugPrint("vertical midpoint $verticalMidpoint");
final halfMaxWidth = maxWidth / 2;
final hasLeftOverflow = (horizontalMidpoint - halfMaxWidth) < 0;
final hasRightOverflow = (horizontalMidpoint + halfMaxWidth) >
MediaQuery.of(context).size.width;
(MediaQuery.of(context).size.width - columnWidth);
hasTopOverflow = (verticalMidpoint - maxHeight) < 0;
double xOffset = 0;
@ -138,24 +143,26 @@ class OverlayUtil {
if (hasLeftOverflow) {
xOffset = (horizontalMidpoint - halfMaxWidth - 10) * -1;
} else if (hasRightOverflow) {
xOffset = MediaQuery.of(context).size.width -
xOffset = (MediaQuery.of(context).size.width - columnWidth) -
(horizontalMidpoint + halfMaxWidth + 10);
}
offset = Offset(xOffset, 0);
}
final Widget child = Material(
borderOnForeground: false,
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: OverlayContainer(
cardToShow: cardToShow,
borderColor: borderColor,
maxHeight: maxHeight,
maxWidth: maxWidth,
isScrollable: isScrollable,
),
);
final Widget child = addBorder
? Material(
borderOnForeground: false,
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
child: OverlayContainer(
cardToShow: cardToShow,
borderColor: borderColor,
maxHeight: maxHeight,
maxWidth: maxWidth,
isScrollable: isScrollable,
),
)
: cardToShow;
showOverlay(
context: context,

View file

@ -0,0 +1,21 @@
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
class ConstructForm {
String form;
ConstructIdentifier cId;
ConstructForm(
this.form,
this.cId,
);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ConstructForm && other.form == form && other.cId == cId;
}
@override
int get hashCode => form.hashCode ^ cId.hashCode;
}

View file

@ -1,21 +1,27 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix_api_lite/utils/try_get_map_extension.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/emojis/emoji_stack.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/pangea/message_token_text/message_token_button.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class ConstructIdentifier {
final String lemma;
@ -111,28 +117,49 @@ class ConstructIdentifier {
uses: [],
);
String? get userSetEmoji {
if (type == ConstructTypeEnum.morph) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception("Morphs should not have userSetEmoji"),
data: toJson(),
);
return null;
List<String> get userSetEmoji => userLemmaInfo?.emojis ?? [];
String? get userSetMeaning => userLemmaInfo?.meaning;
UserSetLemmaInfo? get userLemmaInfo {
switch (type) {
case ConstructTypeEnum.vocab:
final dynamic lemmaInfoContent = MatrixState
.pangeaController.matrixState.client
.analyticsRoomLocal()
?.getState(PangeaEventTypes.userSetLemmaInfo, string)
?.content;
if (lemmaInfoContent != null && lemmaInfoContent is Map) {
try {
return UserSetLemmaInfo.fromJson(
lemmaInfoContent as Map<String, dynamic>,
);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
data: {
"construct": string,
"content": lemmaInfoContent,
},
s: s,
);
return null;
}
} else {
return null;
}
case ConstructTypeEnum.morph:
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception("Morphs should not have userSetEmoji"),
data: toJson(),
);
return null;
}
if (type == ConstructTypeEnum.vocab) {
return MatrixState.pangeaController.matrixState.client
.analyticsRoomLocal()
?.getState(PangeaEventTypes.userChosenEmoji, string)
?.content
.tryGet<String>(ModelKey.emoji);
}
return null;
}
/// [setEmoji] sets the emoji for the lemma
/// NOTE: assumes that the language of the lemma is the same as the user's current l2
Future<void> setEmoji(String emoji) async {
Future<void> setUserLemmaInfo(UserSetLemmaInfo newLemmaInfo) async {
final analyticsRoom =
MatrixState.pangeaController.matrixState.client.analyticsRoomLocal();
if (analyticsRoom == null) return;
@ -140,40 +167,151 @@ class ConstructIdentifier {
final client = MatrixState.pangeaController.matrixState.client;
final syncFuture = client.onRoomState.stream.firstWhere((event) {
return event.roomId == analyticsRoom.id &&
event.state.type == PangeaEventTypes.userChosenEmoji;
event.state.type == PangeaEventTypes.userSetLemmaInfo;
});
client.setRoomStateWithKey(
analyticsRoom.id,
PangeaEventTypes.userChosenEmoji,
PangeaEventTypes.userSetLemmaInfo,
string,
{ModelKey.emoji: emoji},
UserSetLemmaInfo(
emojis: newLemmaInfo.emojis ?? userLemmaInfo?.emojis,
meaning: newLemmaInfo.meaning ?? userLemmaInfo?.meaning,
).toJson(),
);
await syncFuture;
} catch (err, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: err,
data: {
"construct": string,
"emoji": emoji,
},
data: newLemmaInfo.toJson(),
s: s,
);
}
}
// [getEmojiChoices] gets the emoji choices for the lemma
// assumes that the language of the lemma is the same as the user's current l2
Future<List<String>> getEmojiChoices() => LemmaInfoRepo.get(
/// [lemmmaLang] if not set, assumed to be userL2
Future<LemmaInfoResponse> getLemmaInfo([
String? lemmaLang,
String? userl1,
]) =>
LemmaInfoRepo.get(
LemmaInfoRequest(
lemma: lemma,
partOfSpeech: category,
lemmaLang: MatrixState
.pangeaController.languageController.userL2?.langCode ??
LanguageKeys.unknownLanguage,
userL1: MatrixState
.pangeaController.languageController.userL1?.langCode ??
lemmaLang: lemmaLang ??
MatrixState
.pangeaController.languageController.userL2?.langCodeShort ??
LanguageKeys.defaultLanguage,
userL1: userl1 ??
MatrixState
.pangeaController.languageController.userL1?.langCodeShort ??
LanguageKeys.defaultLanguage,
lemma: lemma,
),
).then((onValue) => onValue.emoji);
);
bool get isContentWord =>
PartOfSpeechEnumExtensions.fromString(category)?.isContentWord ?? false;
/// [form] should be passed if available and is required for morphId
bool isActivityProbablyLevelAppropriate(ActivityTypeEnum a, String? form) {
switch (a) {
case ActivityTypeEnum.wordMeaning:
final double contentModifier = isContentWord ? 0.5 : 1;
if (daysSinceLastEligibleUseForMeaning <
3 * constructUses.points * contentModifier) {
return false;
}
return true;
case ActivityTypeEnum.emoji:
return userSetEmoji.length < maxEmojisPerLemma;
case ActivityTypeEnum.morphId:
if (form == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception(
"form is null in isActivityProbablyLevelAppropriate for morphId",
),
data: {
"activity": a,
"construct": toJson(),
},
);
return false;
}
final uses = constructUses.uses
.where((u) => u.form == form)
.map((u) => u.timeStamp)
.toList();
if (uses.isEmpty) return true;
final lastUsed = uses.reduce((a, b) => a.isAfter(b) ? a : b);
return DateTime.now().difference(lastUsed).inDays >
1 * constructUses.points;
case ActivityTypeEnum.wordFocusListening:
final pos = PartOfSpeechEnumExtensions.fromString(lemma) ??
PartOfSpeechEnumExtensions.fromString(category);
if (pos == null) {
debugger(when: kDebugMode);
return false;
}
return pos.canBeHeard;
default:
debugger(when: kDebugMode);
ErrorHandler.logError(
e: Exception(
"Activity type $a not handled in ConstructIdentifier.isActivityProbablyLevelAppropriate",
),
data: {
"activity": a,
"construct": toJson(),
},
);
return false;
}
}
/// days since last eligible use for meaning
/// this is the number of days since the last time the user used this word
/// in a way that would engage with the meaning of the word
/// importantly, this excludes emoji activities
/// we want users to be able to do an emoji activity as a ramp up to
/// a word meaning activity
int get daysSinceLastEligibleUseForMeaning {
final times = constructUses.uses
.where(
(u) =>
u.useType.sentByUser ||
ActivityTypeEnum.wordMeaning.associatedUseTypes
.contains(u.useType) ||
ActivityTypeEnum.messageMeaning.associatedUseTypes
.contains(u.useType),
)
.map((u) => u.timeStamp)
.toList();
if (times.isEmpty) return 1000;
// return the most recent timestamp
final last = times.reduce((a, b) => a.isAfter(b) ? a : b);
return DateTime.now().difference(last).inDays;
}
Widget get visual {
switch (type) {
case ConstructTypeEnum.vocab:
return EmojiStack(emoji: userSetEmoji);
case ConstructTypeEnum.morph:
return MorphIcon(
morphFeature: MorphFeaturesEnumExtension.fromString(category),
morphTag: lemma,
);
}
}
}

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class EmojiStack extends StatelessWidget {
const EmojiStack({
super.key,
required List<String> emoji,
this.style,
}) : _emoji = emoji;
final List<String> _emoji;
final TextStyle? style;
@override
Widget build(BuildContext context) {
// return Text(
// _emoji.first,
// style: style,
// );
return Text(
_emoji.join(''),
style: style,
);
// return Stack(
// children: [
// for (final emoji in _emoji)
// Positioned(
// left: _emoji.indexOf(emoji) * style!.fontSize! * 0.5,
// child: Text(
// emoji,
// style: style,
// ),
// ),
// ],
// );
}
}

View file

@ -8,6 +8,7 @@ class PangeaEventTypes {
// static const studentAnalyticsSummary = "pangea.usranalytics";
static const summaryAnalytics = "pangea.summaryAnalytics";
static const construct = "pangea.construct";
static const userSetLemmaInfo = "p.user_lemma_info";
static const constructSummary = "pangea.construct_summary";
static const userChosenEmoji = "p.emoji";

View file

@ -1,12 +1,7 @@
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart';
import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/choreographer/repo/full_text_translation_repo.dart';
@ -17,13 +12,17 @@ import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart'
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/representation_content_model.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/spaces/models/space_model.dart';
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/audio_encoding_enum.dart';
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../../../widgets/matrix.dart';
import '../../choreographer/enums/use_type.dart';
import '../../common/utils/error_handler.dart';

View file

@ -2,13 +2,7 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/markdown.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/choreographer/event_wrappers/pangea_choreo_event.dart';
import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart';
import 'package:fluffychat/pangea/choreographer/models/language_detection_model.dart';
@ -20,7 +14,14 @@ import 'package:fluffychat/pangea/events/models/representation_content_model.dar
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/events/repo/token_api_models.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/markdown.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class RepresentationEvent {
Event? _event;
@ -283,4 +284,42 @@ class RepresentationEvent {
}
return null;
}
List<PangeaToken> get tokensToSave =>
tokens?.where((token) => token.lemma.saveVocab).toList() ?? [];
// List<ConstructIdentifier> get allTokenMorphsToConstructIdentifiers => tokens?.map((t) => t.morphConstructIds).toList() ??
// [];
/// get allTokenMorphsToConstructIdentifiers
Set<MorphFeaturesEnum> get morphFeatureSetToPractice =>
MorphFeaturesEnum.values.where((feature) {
// pos is always included
if (feature == MorphFeaturesEnum.Pos) {
return true;
}
return tokens?.any((token) => token.morph.containsKey(feature.name)) ??
false;
}).toSet();
Set<PartOfSpeechEnum> posSetToPractice(ActivityTypeEnum a) =>
PartOfSpeechEnum.values.where((pos) {
// some pos are not eligible for practice at all
if (!pos.eligibleForPractice(a)) {
return false;
}
return tokens?.any(
(token) => token.pos.toLowerCase() == pos.name.toLowerCase(),
) ??
false;
}).toSet();
List<String> tagsByFeature(MorphFeaturesEnum feature) {
return tokens
?.where((t) => t.morph.containsKey(feature.name))
.map((t) => t.morph[feature.name])
.cast<String>()
.toList() ??
[];
}
}

View file

@ -1,19 +1,17 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/models/representation_content_model.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
extension PangeaEvent on Event {
V getPangeaContent<V>() {

View file

@ -1,9 +1,6 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
@ -13,10 +10,18 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_repo.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import '../../common/constants/model_keys.dart';
import '../../lemmas/lemma.dart';
@ -153,44 +158,16 @@ class PangeaToken {
/// alias for the end of the token ie offset + length
int get end => text.offset + text.length;
bool get isContentWord =>
["NOUN", "VERB", "ADJ", "ADV"].contains(pos) && lemma.saveVocab;
bool get isContentWord => vocabConstructID.isContentWord;
String get analyticsDebugPrint =>
"content: ${text.content} isContentWord: $isContentWord total_xp:$xp vocab_construct_xp: ${vocabConstruct.points} daysSincelastUseInWordMeaning ${daysSinceLastUseByType(ActivityTypeEnum.wordMeaning)}";
"content: ${text.content} isContentWord: $isContentWord vocab_construct_xp: ${vocabConstruct.points} daysSincelastUseInWordMeaning ${daysSinceLastUseByType(ActivityTypeEnum.wordMeaning, null)}";
bool get canBeDefined =>
[
"ADJ",
"ADP",
"ADV",
"AUX",
"CCONJ",
"DET",
"INTJ",
"NOUN",
"NUM",
"PRON",
"SCONJ",
"VERB",
].contains(pos) &&
lemma.saveVocab;
PartOfSpeechEnumExtensions.fromString(pos)?.canBeDefined ?? false;
bool get canBeHeard =>
[
"ADJ",
"ADV",
"AUX",
"DET",
"INTJ",
"NOUN",
"NUM",
"PRON",
"PROPN",
"SCONJ",
"VERB",
].contains(pos) &&
lemma.saveVocab;
PartOfSpeechEnumExtensions.fromString(pos)?.canBeHeard ?? false;
/// Given a [type] and [metadata], returns a [OneConstructUse] for this lemma
OneConstructUse toVocabUse(
@ -316,9 +293,10 @@ class PangeaToken {
debugger(when: kDebugMode);
return false;
}
return morphConstruct(morphFeature, morphTag)
.uses
.any((u) => u.useType == a.correctUse && u.form == text.content);
return morphConstruct(morphFeature)?.uses.any(
(u) => u.useType == a.correctUse && u.form == text.content,
) ??
false;
case ActivityTypeEnum.messageMeaning:
debugger(when: kDebugMode);
ErrorHandler.logError(
@ -332,49 +310,45 @@ class PangeaToken {
bool _isActivityProbablyLevelAppropriate(
ActivityTypeEnum a, [
String? morphFeature,
String? morphTag,
]) {
switch (a) {
case ActivityTypeEnum.wordMeaning:
final double contentModifier = isContentWord ? 0.5 : 1;
// TODO: turn this back on, see how much it fires, and discuss
// debugPrint(
// "days since last eligible use for meaning: $_daysSinceLastEligibleUseForMeaning for ${text.content}",
// );
if (_daysSinceLastEligibleUseForMeaning <
3 * vocabConstruct.points * contentModifier) {
return false;
}
return true;
return vocabConstructID.isActivityProbablyLevelAppropriate(
a,
text.content,
);
case ActivityTypeEnum.wordFocusListening:
return !didActivitySuccessfully(a) || daysSinceLastUseByType(a) > 30;
return !didActivitySuccessfully(a) ||
daysSinceLastUseByType(a, null) > 30;
case ActivityTypeEnum.hiddenWordListening:
return daysSinceLastUseByType(a) > 7;
return daysSinceLastUseByType(a, null) > 7;
case ActivityTypeEnum.lemmaId:
return false;
// disabling lemma activities for now
// It has 2 purposes: learning value triangulating our determination of the lemma with
// AI plus user verification.However, displaying the lemma during the meaning activity helps
// It has 2 purposes:
// learning value
// triangulating our determination of the lemma with AI plus user verification.
// However, displaying the lemma during the meaning activity helps
// disambiguate what the meaning activity is about. This is probably more valuable than the
// lemma activity itself. The piping for the lemma activity will stay there if we want to turn
//it back on, maybe in select instances.
// it back on, maybe in select instances.
// return _didActivitySuccessfully(ActivityTypeEnum.wordMeaning) &&
// daysSinceLastUseByType(a) > 7;
case ActivityTypeEnum.emoji:
return vocabConstructID.isActivityProbablyLevelAppropriate(
a,
text.content,
);
case ActivityTypeEnum.messageMeaning:
return true;
case ActivityTypeEnum.morphId:
if (morphFeature == null || morphTag == null) {
debugger(when: kDebugMode);
return false;
}
// return _daysSinceLastUsedMorphForm(morphFeature, morphTag) >
// 2 * morphConstruct(morphFeature, morphTag).points;
return _daysSinceLastUsedMorphForm(morphFeature, morphTag) > 1;
return morphFeature != null
? morphIdByFeature(morphFeature)
?.isActivityProbablyLevelAppropriate(a, text.content) ??
false
: false;
}
}
@ -401,17 +375,13 @@ class PangeaToken {
required String? tag,
}) {
return isActivityBasicallyEligible(a, feature, tag) &&
_isActivityProbablyLevelAppropriate(a, feature, tag);
_isActivityProbablyLevelAppropriate(a, feature);
}
ConstructUses get vocabConstruct =>
MatrixState.pangeaController.getAnalytics.constructListModel
.getConstructUses(
ConstructIdentifier(
lemma: lemma.text,
type: ConstructTypeEnum.vocab,
category: pos,
),
vocabConstructID,
) ??
ConstructUses(
lemma: lemma.text,
@ -420,63 +390,32 @@ class PangeaToken {
uses: [],
);
ConstructUses morphConstruct(String morphFeature, String morphTag) =>
MatrixState.pangeaController.getAnalytics.constructListModel
.getConstructUses(
ConstructIdentifier(
lemma: morphTag,
type: ConstructTypeEnum.morph,
category: morphFeature,
),
) ??
ConstructUses(
lemma: morphTag,
constructType: ConstructTypeEnum.morph,
category: morphFeature,
uses: [],
);
ConstructUses? morphConstruct(String morphFeature) =>
morphIdByFeature(morphFeature)?.constructUses;
int get xp {
return constructs.fold<int>(
0,
(previousValue, element) => previousValue + element.points,
ConstructIdentifier? morphIdByFeature(String feature) {
final tag = getMorphTag(feature);
if (tag == null) return null;
return ConstructIdentifier(
lemma: tag,
type: ConstructTypeEnum.morph,
category: feature,
);
}
/// days since last eligible use for meaning
/// this is the number of days since the last time the user used this word
/// in a way that would engage with the meaning of the word
/// importantly, this excludes emoji activities
/// we want users to be able to do an emoji activity as a ramp up to
/// a word meaning activity
int get _daysSinceLastEligibleUseForMeaning {
final times = vocabConstruct.uses
.where(
(u) =>
u.useType.sentByUser ||
ActivityTypeEnum.wordMeaning.associatedUseTypes
.contains(u.useType) ||
ActivityTypeEnum.messageMeaning.associatedUseTypes
.contains(u.useType),
)
.map((u) => u.timeStamp)
.toList();
/// lastUsed by activity type, construct and form
DateTime? _lastUsedByActivityType(ActivityTypeEnum a, String? feature) {
if (a == ActivityTypeEnum.morphId && feature == null) {
debugger(when: kDebugMode);
return null;
}
final ConstructIdentifier? cId = a == ActivityTypeEnum.morphId
? morphIdByFeature(feature!)
: vocabConstructID;
if (times.isEmpty) return 1000;
if (cId == null) return null;
// return the most recent timestamp
final last = times.reduce((a, b) => a.isAfter(b) ? a : b);
return DateTime.now().difference(last).inDays;
}
/// lastUsed by activity type
DateTime? _lastUsedByActivityType(ActivityTypeEnum a) {
final List<ConstructUses> filteredConstructs =
constructs.where((c) => a.constructFilter(c.id)).toList();
final correctUseTimestamps = filteredConstructs
.expand((c) => c.uses)
final correctUseTimestamps = cId.constructUses.uses
.where((u) => u.form == text.content)
.map((u) => u.timeStamp)
.toList();
@ -488,26 +427,13 @@ class PangeaToken {
}
/// daysSinceLastUse by activity type
int daysSinceLastUseByType(ActivityTypeEnum a) {
final lastUsed = _lastUsedByActivityType(a);
/// returns 1000 if there is no last use
int daysSinceLastUseByType(ActivityTypeEnum a, String? feature) {
final lastUsed = _lastUsedByActivityType(a, feature);
if (lastUsed == null) return 1000;
return DateTime.now().difference(lastUsed).inDays;
}
int _daysSinceLastUsedMorphForm(String morphFeature, String morphTag) {
final uses = morphConstruct(morphFeature, morphTag)
.uses
.where((u) => u.form == text.content)
.map((u) => u.timeStamp)
.toList();
if (uses.isEmpty) return 1000;
final lastUsed = uses.reduce((a, b) => a.isAfter(b) ? a : b);
return DateTime.now().difference(lastUsed).inDays;
}
List<ConstructIdentifier> get _constructIDs {
final List<ConstructIdentifier> ids = [];
ids.add(
@ -538,7 +464,18 @@ class PangeaToken {
.cast<ConstructUses>()
.toList();
Future<List<String>> getEmojiChoices() => vocabConstructID.getEmojiChoices();
Future<List<String>> getEmojiChoices() => LemmaInfoRepo.get(
LemmaInfoRequest(
lemma: lemma.text,
partOfSpeech: pos,
lemmaLang: MatrixState
.pangeaController.languageController.userL2?.langCode ??
LanguageKeys.unknownLanguage,
userL1: MatrixState
.pangeaController.languageController.userL1?.langCode ??
LanguageKeys.defaultLanguage,
),
).then((onValue) => onValue.emoji);
ConstructIdentifier get vocabConstructID => ConstructIdentifier(
lemma: lemma.text,
@ -546,11 +483,14 @@ class PangeaToken {
category: pos,
);
Future<void> setEmoji(String emoji) => vocabConstructID.setEmoji(emoji);
/// [setEmoji] sets the emoji for the lemma
/// NOTE: assumes that the language of the lemma is the same as the user's current l2
Future<void> setEmoji(List<String> emojis) =>
vocabConstructID.setUserLemmaInfo(UserSetLemmaInfo(emojis: emojis));
/// [getEmoji] gets the emoji for the lemma
/// NOTE: assumes that the language of the lemma is the same as the user's current l2
String? getEmoji() => vocabConstructID.userSetEmoji;
List<String> getEmoji() => vocabConstructID.userSetEmoji;
String get xpEmoji => vocabConstruct.xpEmoji;
@ -582,9 +522,9 @@ class PangeaToken {
/// initial default input mode for a token
MessageMode get modeForToken {
if (getEmoji() == null) {
return MessageMode.wordEmoji;
}
// if (getEmoji() == null) {
// return MessageMode.wordEmoji;
// }
if (shouldDoActivity(
a: ActivityTypeEnum.wordMeaning,
@ -643,4 +583,62 @@ class PangeaToken {
});
return morphEntries;
}
bool shouldDoActivityByMessageMode(MessageMode mode) {
// debugPrint("should do activity for ${text.content} in $mode");
return mode.associatedActivityType != null
? shouldDoActivity(
a: mode.associatedActivityType!,
feature: null,
tag: null,
)
: false;
}
List<ConstructIdentifier> get allConstructIds => _constructIDs;
List<ConstructIdentifier> get morphConstructIds => morph.entries
.map(
(e) => ConstructIdentifier(
lemma: e.key,
type: ConstructTypeEnum.morph,
category: e.value,
),
)
.toList();
List<ConstructIdentifier> get morphsBasicallyEligibleForPracticeByPriority =>
MorphFeaturesEnumExtension.eligibleForPractice.where((f) {
return f == MorphFeaturesEnum.Pos || getMorphTag(f.name) != null;
}).map((f) {
if (f == MorphFeaturesEnum.Pos) {
return ConstructIdentifier(
lemma: pos,
type: ConstructTypeEnum.morph,
category: f.name,
);
}
return ConstructIdentifier(
lemma: getMorphTag(f.name)!,
type: ConstructTypeEnum.morph,
category: f.name,
);
}).toList();
bool hasMorph(ConstructIdentifier cId) {
if (cId.category == "pos") {
return morph["pos"].toString().toLowerCase() == cId.lemma.toLowerCase();
}
return morph.entries.any(
(e) =>
e.key.toLowerCase() == cId.lemma.toLowerCase() &&
e.value.toString().toLowerCase() == cId.category.toLowerCase(),
);
}
/// [0,infinity) - a lower number means higher priority
int activityPriorityScore(ActivityTypeEnum a, String? morphFeature) {
return daysSinceLastUseByType(a, morphFeature) *
(vocabConstructID.isContentWord ? 1 : 2);
}
}

View file

@ -42,4 +42,6 @@ class PangeaTokenText {
@override
int get hashCode => offset.hashCode ^ content.hashCode ^ length.hashCode;
String get uniqueKey => "$content-$offset-$length";
}

View file

@ -1,16 +1,45 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.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/toolbar/widgets/message_token_text.dart';
import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart';
import 'package:flutter/material.dart';
class TokenPosition {
/// Start index of the full substring in the message
final int start;
/// End index of the full substring in the message
final int end;
/// Start index of the token in the message
final int tokenStart;
/// End index of the token in the message
final int tokenEnd;
final bool selected;
final bool hideContent;
final PangeaToken? token;
final bool isHighlighted;
const TokenPosition({
required this.start,
required this.end,
required this.tokenStart,
required this.tokenEnd,
required this.hideContent,
required this.selected,
required this.isHighlighted,
this.token,
});
}
class MessageTextUtil {
static List<TokenPosition>? getTokenPositions(
PangeaMessageEvent pangeaMessageEvent, {
MessageAnalyticsEntry? messageAnalyticsEntry,
bool Function(PangeaToken)? isSelected,
bool Function(PangeaToken)? isHighlighted,
}) {
try {
if (pangeaMessageEvent.messageDisplayRepresentation?.tokens == null) {
@ -49,6 +78,7 @@ class MessageTextUtil {
tokenEnd: startIndex,
hideContent: false,
selected: (isSelected?.call(token) ?? false) && !hasHiddenContent,
isHighlighted: isHighlighted?.call(token) ?? false,
),
);
}
@ -91,6 +121,7 @@ class MessageTextUtil {
selected: (isSelected?.call(token) ?? false) &&
!hideContent &&
!hasHiddenContent,
isHighlighted: isHighlighted?.call(token) ?? false,
),
);

View file

@ -20,12 +20,15 @@ enum InstructionsEnum {
missingVoice,
clickBestOption,
completeActivitiesToUnlock,
lemmaMeaning,
chooseLemmaMeaning,
activityPlannerOverview,
ttsDisabled,
chooseEmoji,
chooseWordAudio,
chooseMorphs,
analyticsVocabList,
morphAnalyticsList,
readingAssistanceOverview,
}
extension InstructionsEnumExtension on InstructionsEnum {
@ -43,6 +46,7 @@ extension InstructionsEnumExtension on InstructionsEnum {
return l10n.missingVoiceTitle;
case InstructionsEnum.ttsDisabled:
return l10n.ttsDisbledTitle;
case InstructionsEnum.chooseWordAudio:
case InstructionsEnum.chooseEmoji:
case InstructionsEnum.activityPlannerOverview:
case InstructionsEnum.clickAgainToDeselect:
@ -51,9 +55,11 @@ extension InstructionsEnumExtension on InstructionsEnum {
case InstructionsEnum.translationChoices:
case InstructionsEnum.clickBestOption:
case InstructionsEnum.completeActivitiesToUnlock:
case InstructionsEnum.lemmaMeaning:
case InstructionsEnum.chooseLemmaMeaning:
case InstructionsEnum.chooseMorphs:
case InstructionsEnum.analyticsVocabList:
case InstructionsEnum.morphAnalyticsList:
case InstructionsEnum.readingAssistanceOverview:
ErrorHandler.logError(
e: Exception("No title for this instruction"),
m: 'InstructionsEnumExtension.title',
@ -66,6 +72,21 @@ extension InstructionsEnumExtension on InstructionsEnum {
}
}
// IconData? get icon {
// switch (this) {
// case InstructionsEnum.itInstructions:
// return Icons.translate;
// case InstructionsEnum.clickMessage:
// return Icons.touch_app;
// case InstructionsEnum.blurMeansTranslate:
// return Icons.blur_on;
// case InstructionsEnum.tooltipInstructions:
// return Icons.help;
// case InstructionsEnum.missingVoice:
// return Icons.mic_off;
// }
// }
String body(L10n l10n) {
switch (this) {
case InstructionsEnum.itInstructions:
@ -92,18 +113,24 @@ extension InstructionsEnumExtension on InstructionsEnum {
return l10n.clickBestOption;
case InstructionsEnum.completeActivitiesToUnlock:
return l10n.completeActivitiesToUnlock;
case InstructionsEnum.lemmaMeaning:
return l10n.lemmaMeaningInstructionsBody;
case InstructionsEnum.chooseLemmaMeaning:
return l10n.chooseLemmaMeaningInstructionsBody;
case InstructionsEnum.activityPlannerOverview:
return l10n.activityPlannerOverviewInstructionsBody;
case InstructionsEnum.chooseEmoji:
return l10n.chooseEmojiInstructionsBody;
case InstructionsEnum.ttsDisabled:
return l10n.ttsDisabledBody;
case InstructionsEnum.chooseWordAudio:
return l10n.chooseWordAudioInstructionsBody;
case InstructionsEnum.chooseMorphs:
return l10n.chooseMorphsInstructionsBody;
case InstructionsEnum.analyticsVocabList:
return l10n.analyticsVocabListBody;
case InstructionsEnum.morphAnalyticsList:
return l10n.morphAnalyticsListBody;
case InstructionsEnum.readingAssistanceOverview:
return l10n.readingAssistanceOverviewBody;
}
}

View file

@ -1,17 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class InstructionsInlineTooltip extends StatefulWidget {
final InstructionsEnum instructionsEnum;
final bool bold;
const InstructionsInlineTooltip({
super.key,
required this.instructionsEnum,
this.bold = false,
});
@override
@ -20,14 +20,27 @@ class InstructionsInlineTooltip extends StatefulWidget {
}
class InstructionsInlineTooltipState extends State<InstructionsInlineTooltip>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
bool _isToggledOff = true;
late AnimationController _controller;
late Animation<double> _animation;
@override
void didUpdateWidget(covariant InstructionsInlineTooltip oldWidget) {
debugPrint("InstructionsInlineTooltip didUpdateWidget");
if (oldWidget.instructionsEnum != widget.instructionsEnum) {
setToggled();
}
super.didUpdateWidget(oldWidget);
}
@override
void initState() {
super.initState();
setToggled();
}
void setToggled() {
_isToggledOff = widget.instructionsEnum.isToggledOff;
// Initialize AnimationController and Animation
@ -43,6 +56,8 @@ class InstructionsInlineTooltipState extends State<InstructionsInlineTooltip>
// Start in correct state
if (!_isToggledOff) _controller.forward();
setState(() {});
}
@override
@ -69,7 +84,7 @@ class InstructionsInlineTooltipState extends State<InstructionsInlineTooltip>
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
color: Theme.of(context).colorScheme.primary.withAlpha(5),
color: AppConfig.gold.withAlpha(widget.bold ? 80 : 10),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
@ -87,7 +102,8 @@ class InstructionsInlineTooltipState extends State<InstructionsInlineTooltip>
child: Center(
child: Text(
widget.instructionsEnum.body(L10n.of(context)),
style: Theme.of(context).textTheme.bodyLarge,
style: Theme.of(context).textTheme.titleLarge ??
Theme.of(context).textTheme.bodyLarge,
textAlign: TextAlign.center,
),
),

View file

@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart';
class ResetInstructionsListTile extends StatelessWidget {
const ResetInstructionsListTile({
super.key,
required this.controller,
});
final SettingsLearningController controller;
@override
Widget build(BuildContext context) {
//TODO: add to L10n
return ListTile(
leading: const Icon(Icons.lightbulb),
title: const Text(
"Reset instruction tooltips",
),
subtitle: const Text(
"Click to show instruction tooltips like for a brand new user.",
),
onTap: () => showAdaptiveDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text(
"Reset instruction tooltips?",
),
actions: [
TextButton(
onPressed: () {
controller.resetInstructionTooltips();
Navigator.of(context).pop();
},
child: const Text("Reset"),
),
TextButton(
onPressed: () {
Navigator.of(context).pop();
},
child: const Text("Cancel"),
),
],
);
},
),
);
}
}

View file

@ -4,6 +4,7 @@ import 'package:country_picker/country_picker.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/instructions/instruction_settings.dart';
import 'package:fluffychat/pangea/learning_settings/enums/language_level_type_enum.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/pages/settings_learning_view.dart';
@ -118,6 +119,20 @@ class SettingsLearningController extends State<SettingsLearning> {
}
}
Future<void> resetInstructionTooltips() async {
await showFutureLoadingDialog(
context: context,
future: () async => pangeaController.userController.updateProfile(
(profile) {
profile.instructionSettings = InstructionSettings();
return profile;
},
waitForDataInSync: true,
),
);
if (mounted) setState(() {});
}
Future<void> setSelectedLanguage({
LanguageModel? sourceLanguage,
LanguageModel? targetLanguage,

View file

@ -1,16 +1,11 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:app_settings/app_settings.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:url_launcher/url_launcher_string.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/chat_settings/widgets/language_level_dropdown.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
import 'package:fluffychat/pangea/instructions/reset_instructions_list_tile.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart';
import 'package:fluffychat/pangea/learning_settings/widgets/country_picker_tile.dart';
@ -19,6 +14,10 @@ import 'package:fluffychat/pangea/learning_settings/widgets/p_settings_switch_li
import 'package:fluffychat/pangea/spaces/models/space_model.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:url_launcher/url_launcher_string.dart';
class SettingsLearningView extends StatelessWidget {
final SettingsLearningController controller;
@ -255,6 +254,7 @@ class SettingsLearningView extends StatelessWidget {
activeColor: AppConfig.activeToggleColor,
contentPadding: EdgeInsets.zero,
),
ResetInstructionsListTile(controller: controller),
],
),
),

View file

@ -0,0 +1,73 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/pangea/message_token_text/message_token_button.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart';
class LemmaEmojiRow extends StatelessWidget {
final ConstructIdentifier cId;
final VoidCallback onTap;
final bool isSelected;
/// if a setState is defined then we're in a context where
/// we allow removing an emoji
/// later we'll probably want to allow this everywhere
final void Function()? removeCallback;
const LemmaEmojiRow({
required this.cId,
required this.onTap,
required this.removeCallback,
this.isSelected = false,
super.key,
});
List<String> get emojis => cId.userSetEmoji;
Future<void> onEmojiTap(String toRemove) async {
await cId.setUserLemmaInfo(
UserSetLemmaInfo(
emojis: emojis.where((e) => e != toRemove).toList(),
),
);
removeCallback!();
}
@override
Widget build(BuildContext context) {
return Row(
children: [
for (var i = 0; i < maxEmojisPerLemma; i++)
Container(
height: 40,
width: 40,
alignment: Alignment.center,
child: i < emojis.length
? GestureDetector(
onTap: removeCallback == null
? null
: () => onEmojiTap(emojis[i]),
child: Text(
emojis[i],
style: Theme.of(context).textTheme.bodyLarge,
),
)
: WordZoomActivityButton(
icon: Icon(
Icons.add_reaction_outlined,
color: isSelected
? Theme.of(context).colorScheme.primary
: null,
),
isSelected: isSelected,
onPressed: onTap,
opacity: isSelected ? 1 : 0.4,
tooltip: MessageMode.wordEmoji.title(context),
),
),
],
);
}
}

View file

@ -1,15 +1,18 @@
import 'dart:convert';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
import 'dart:developer';
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/events/models/content_feedback.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
import 'package:fluffychat/pangea/message_token_text/message_token_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
class LemmaInfoRepo {
static final GetStorage _lemmaStorage = GetStorage('lemma_storage');
@ -20,38 +23,22 @@ class LemmaInfoRepo {
_lemmaStorage.write(request.storageKey, response.toJson());
}
static Future<LemmaInfoResponse> get(
LemmaInfoRequest request, [
String? feedback,
bool useExpireAt = false,
]) async {
static Future<LemmaInfoResponse> _fetch(LemmaInfoRequest request) async {
final cachedJson = _lemmaStorage.read(request.storageKey);
final cached =
cachedJson == null ? null : LemmaInfoResponse.fromJson(cachedJson);
if (cached != null) {
if (feedback == null) {
// at this point we have a cache without feedback
if (!useExpireAt) {
// return cache as is if we're not using expireAt
return cached;
} else if (cached.expireAt != null) {
if (DateTime.now().isBefore(cached.expireAt!)) {
// return cache as is if we're using expireAt and it's set but not expired
return cached;
}
}
// we intentionally do not handle the case of expired at not set because
// old caches won't have them set, and we want to trigger a new
// choreo call
if (DateTime.now().isBefore(cached.expireAt!)) {
// return cache as is if we're using expireAt and it's set but not expired
// debugPrint(
// 'using cached data for ${request.lemma} ${cached.toJson()}',
// );
return cached;
} else {
// we're adding this within the service to avoid needing to have the widgets
// save state including the bad response
request.feedback = ContentFeedback(
cached,
feedback,
);
// if it's expired, remove it
_lemmaStorage.remove(request.storageKey);
}
}
@ -70,6 +57,47 @@ class LemmaInfoRepo {
set(request, response);
// debugPrint(
// 'fetched data for ${request.lemma} ${response.toJson()}',
// );
return response;
}
/// Get lemma info, prefering user set data over fetched data
static Future<LemmaInfoResponse> get(LemmaInfoRequest request) async {
try {
// if the user has either emojis or meaning in the past, use those first
final UserSetLemmaInfo? userSetLemmaInfo = request.cId.userLemmaInfo;
final List<String> emojis = userSetLemmaInfo?.emojis ?? [];
String? meaning = userSetLemmaInfo?.meaning;
// if the user has not set these, fetch from the server
if (emojis.length < maxEmojisPerLemma || meaning == null) {
final LemmaInfoResponse fetched = await _fetch(request);
while (emojis.length < maxEmojisPerLemma && fetched.emoji.isNotEmpty) {
final maybeToAdd = fetched.emoji.removeAt(0);
if (!emojis.contains(maybeToAdd)) {
emojis.add(maybeToAdd);
}
}
meaning ??= fetched.meaning;
} else {
// debugPrint(
// 'using user set data for ${request.lemma} ${userSetLemmaInfo?.toJson()}',
// );
}
return LemmaInfoResponse(
emoji: emojis,
meaning: meaning,
);
} catch (e) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, data: request.toJson());
rethrow;
}
}
}

View file

@ -1,3 +1,5 @@
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/content_feedback.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
@ -44,4 +46,10 @@ class LemmaInfoRequest {
String get storageKey {
return 'l:$lemma,p:$partOfSpeech,lang:$lemmaLang,l1:$userL1';
}
ConstructIdentifier get cId => ConstructIdentifier(
lemma: lemma,
type: ConstructTypeEnum.vocab,
category: partOfSpeech,
);
}

View file

@ -1,4 +1,5 @@
import 'package:fluffychat/pangea/events/models/content_feedback.dart';
import 'package:fluffychat/pangea/message_token_text/message_token_button.dart';
class LemmaInfoResponse implements JsonSerializable {
final List<String> emoji;
@ -13,7 +14,12 @@ class LemmaInfoResponse implements JsonSerializable {
factory LemmaInfoResponse.fromJson(Map<String, dynamic> json) {
return LemmaInfoResponse(
emoji: (json['emoji'] as List<dynamic>).map((e) => e as String).toList(),
// NOTE: This is a workaround for the fact that the server sometimes sends more than 3 emojis
emoji: (json['emoji'] as List<dynamic>)
.map((e) => e as String)
.toList()
.take(maxEmojisPerLemma)
.toList(),
meaning: json['meaning'] as String,
expireAt: json['expireAt'] == null
? null

View file

@ -0,0 +1,34 @@
class UserSetLemmaInfo {
final String? meaning;
final List<String>? emojis;
UserSetLemmaInfo({
this.emojis,
this.meaning,
});
factory UserSetLemmaInfo.fromJson(Map<String, dynamic> json) {
return UserSetLemmaInfo(
emojis: json["emojis"] != null ? List<String>.from(json["emojis"]) : null,
meaning: json['meaning'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'emojis': emojis,
'meaning': meaning,
};
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is UserSetLemmaInfo &&
runtimeType == other.runtimeType &&
emojis == other.emojis &&
meaning == other.meaning;
@override
int get hashCode => emojis.hashCode ^ meaning.hashCode;
}

View file

@ -0,0 +1,46 @@
import 'package:flutter/material.dart';
class HiddenText extends StatelessWidget {
final String text;
final TextStyle style;
const HiddenText({
super.key,
required this.text,
required this.style,
});
@override
Widget build(BuildContext context) {
final TextPainter textPainter = TextPainter(
text: TextSpan(text: text, style: style),
textDirection: TextDirection.ltr,
)..layout();
final textWidth = textPainter.size.width;
final textHeight = textPainter.size.height;
textPainter.dispose();
return SizedBox(
height: textHeight,
child: Stack(
children: [
Container(
width: textWidth,
height: textHeight,
color: Colors.transparent,
),
Positioned(
bottom: 0,
child: Container(
width: textWidth,
height: 1,
color: style.color,
),
),
],
),
);
}
}

View file

@ -0,0 +1,241 @@
import 'dart:developer';
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constructs/construct_form.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/utils/shrinkable_text.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
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 TargetTokensAndActivityType? activity;
const MessageTokenButton({
super.key,
required this.overlayController,
required this.token,
required this.textStyle,
required this.width,
required this.activity,
this.animate = false,
});
@override
MessageTokenButtonState createState() => MessageTokenButtonState();
}
class MessageTokenButtonState extends State<MessageTokenButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _heightAnimation;
bool _isHovered = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(
milliseconds: AppConfig.overlayAnimationDuration,
// seconds: 5,
),
);
_heightAnimation = Tween<double>(
begin: 0,
end: tokenButtonHeight,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
if (widget.animate) {
_controller.forward();
}
}
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 ||
oldWidget.activity != widget.activity) {
setState(() {});
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
double get textSize =>
widget.textStyle.fontSize ?? tokenButtonDefaultFontSize;
double get emojiSize => textSize * estimatedEmojiWidthRatio;
TextStyle get emojiStyle => widget.textStyle.copyWith(
fontSize: textSize + 4,
);
TargetTokensAndActivityType? get activity => widget.activity;
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(),
// );
}
Widget get content {
final tokenActivity = activity;
if (tokenActivity == null) {
if (MessageMode.wordEmoji == widget.overlayController?.toolbarMode) {
return SizedBox(height: height, child: emojiView);
}
return SizedBox(height: height);
}
if (MessageMode.wordMorph == widget.overlayController?.toolbarMode) {
if (activity?.morphFeature == null) {
debugger(when: kDebugMode);
return SizedBox(height: height);
}
return InkWell(
onHover: (isHovered) => setState(() => _isHovered = isHovered),
onTap: () => widget.overlayController!
.onMorphActivitySelect(widget.token, activity!.morphFeature!),
borderRadius: borderRadius,
child: Container(
height: height,
width: min(widget.width, height),
alignment: Alignment.center,
child: Opacity(
opacity: (widget.overlayController?.selectedToken == widget.token &&
widget.overlayController?.selectedMorph ==
activity?.morphFeature) ||
_isHovered
? 1.0
: 0.5,
child: Icon(
Symbols.toys_and_games,
color: Theme.of(context).colorScheme.primary,
),
// MorphIcon(morphFeature: activity!.morphFeature!, morphTag: null),
),
),
);
}
return DragTarget<ConstructForm>(
builder: (BuildContext context, accepted, rejected) {
final double colorAlpha = 0.3 +
(widget.overlayController?.selectedChoice != null ? 0.3 : 0.0);
return InkWell(
onHover: (isHovered) => setState(() => _isHovered = isHovered),
onTap: widget.overlayController?.selectedChoice != null
? () => widget.overlayController!.onMatchAttempt(
widget.token,
widget.overlayController!.selectedChoice!,
)
: null,
borderRadius: borderRadius,
child: Container(
height: height,
padding: EdgeInsets.only(top: topPadding),
width:
MessageMode.wordMeaning == widget.overlayController?.toolbarMode
? widget.width
: min(widget.width, height),
alignment: Alignment.center,
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withAlpha((colorAlpha * 255).toInt()),
borderRadius: borderRadius,
border: accepted.isNotEmpty ||
(widget.overlayController?.selectedChoice != null &&
_isHovered)
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
),
);
},
onAcceptWithDetails: (details) =>
widget.overlayController!.onMatchAttempt(widget.token, 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,
);
}
}

View file

@ -0,0 +1,29 @@
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
class TokenPositionModel {
/// Start index of the full substring in the message
final int start;
/// End index of the full substring in the message
final int end;
/// Start index of the token in the message
final int tokenStart;
/// End index of the token in the message
final int tokenEnd;
final bool selected;
final bool hideContent;
final PangeaToken? token;
const TokenPositionModel({
required this.start,
required this.end,
required this.tokenStart,
required this.tokenEnd,
required this.hideContent,
required this.selected,
this.token,
});
}

View file

@ -1,57 +0,0 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
// TODO Use the icons that Khue is creating
IconData getIconForMorphFeature(String feature) {
// Define a function to get the icon based on the universal dependency morphological feature (key)
switch (feature.toLowerCase()) {
case 'number':
// google material 123 icon
return Icons.format_list_numbered;
case 'gender':
return Icons.wc;
case 'tense':
return Icons.access_time;
case 'mood':
return Icons.mood;
case 'person':
return Icons.person;
case 'case':
return Icons.format_list_bulleted;
case 'degree':
return Icons.trending_up;
case 'verbform':
return Icons.text_format;
case 'voice':
return Icons.record_voice_over;
case 'aspect':
return Icons.aspect_ratio;
case 'prontype':
return Icons.text_fields;
case 'numtype':
return Icons.format_list_numbered;
case 'poss':
return Icons.account_balance;
case 'reflex':
return Icons.refresh;
case 'foreign':
return Icons.language;
case 'abbr':
return Icons.text_format;
case 'nountype':
return Symbols.abc;
case 'pos':
return Symbols.toys_and_games;
case 'polarity':
return Icons.swap_vert;
case 'definite':
return Icons.check_circle_outline;
case 'prepcase':
return Icons.location_on_outlined;
case 'conjtype':
return Icons.compare_arrows;
default:
return Icons.help_outline;
}
}

View file

@ -1,140 +0,0 @@
// ignore_for_file: constant_identifier_names
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
enum MorphologicalCategories {
Pos,
AdvType,
Aspect,
Case,
ConjType,
Definite,
Degree,
Evident,
Foreign,
Gender,
Mood,
NounType,
NumForm,
NumType,
Number,
NumberPsor,
Person,
Polarity,
Polite,
Poss,
PrepCase,
PronType,
PunctSide,
PunctType,
Reflex,
Tense,
VerbForm,
VerbType,
Voice,
}
extension MorphologicalCategoriesExtension on MorphologicalCategories {
/// Convert enum to string
String toShortString() {
return toString().split('.').last.toLowerCase();
}
/// Convert string to enum
static MorphologicalCategories? fromString(String category) {
final morph = MorphologicalCategories.values.firstWhereOrNull(
(e) =>
e.toShortString() ==
category.toLowerCase().replaceAll(RegExp(r'[,\[\]]'), ''),
);
if (morph == null) {
ErrorHandler.logError(
e: "Missing morphological category",
s: StackTrace.current,
data: {"category": category},
);
}
return morph;
}
String getDisplayCopy(BuildContext context) {
switch (this) {
case MorphologicalCategories.Pos:
return L10n.of(context).grammarCopyPOS;
case MorphologicalCategories.AdvType:
return L10n.of(context).grammarCopyADVTYPE;
case MorphologicalCategories.Aspect:
return L10n.of(context).grammarCopyASPECT;
case MorphologicalCategories.Case:
return L10n.of(context).grammarCopyCASE;
case MorphologicalCategories.ConjType:
return L10n.of(context).grammarCopyCONJTYPE;
case MorphologicalCategories.Definite:
return L10n.of(context).grammarCopyDEFINITE;
case MorphologicalCategories.Degree:
return L10n.of(context).grammarCopyDEGREE;
case MorphologicalCategories.Evident:
return L10n.of(context).grammarCopyEVIDENT;
case MorphologicalCategories.Foreign:
return L10n.of(context).grammarCopyFOREIGN;
case MorphologicalCategories.Gender:
return L10n.of(context).grammarCopyGENDER;
case MorphologicalCategories.Mood:
return L10n.of(context).grammarCopyMOOD;
case MorphologicalCategories.NounType:
return L10n.of(context).grammarCopyNOUNTYPE;
case MorphologicalCategories.NumForm:
return L10n.of(context).grammarCopyNUMFORM;
case MorphologicalCategories.NumType:
return L10n.of(context).grammarCopyNUMTYPE;
case MorphologicalCategories.Number:
return L10n.of(context).grammarCopyNUMBER;
case MorphologicalCategories.NumberPsor:
return L10n.of(context).grammarCopyNUMBERPSOR;
case MorphologicalCategories.Person:
return L10n.of(context).grammarCopyPERSON;
case MorphologicalCategories.Polarity:
return L10n.of(context).grammarCopyPOLARITY;
case MorphologicalCategories.Polite:
return L10n.of(context).grammarCopyPOLITE;
case MorphologicalCategories.Poss:
return L10n.of(context).grammarCopyPOSS;
case MorphologicalCategories.PrepCase:
return L10n.of(context).grammarCopyPREPCASE;
case MorphologicalCategories.PronType:
return L10n.of(context).grammarCopyPRONTYPE;
case MorphologicalCategories.PunctSide:
return L10n.of(context).grammarCopyPUNCTSIDE;
case MorphologicalCategories.PunctType:
return L10n.of(context).grammarCopyPUNCTTYPE;
case MorphologicalCategories.Reflex:
return L10n.of(context).grammarCopyREFLEX;
case MorphologicalCategories.Tense:
return L10n.of(context).grammarCopyTENSE;
case MorphologicalCategories.VerbForm:
return L10n.of(context).grammarCopyVERBFORM;
case MorphologicalCategories.VerbType:
return L10n.of(context).grammarCopyVERBTYPE;
case MorphologicalCategories.Voice:
return L10n.of(context).grammarCopyVOICE;
}
}
}
String? getMorphologicalCategoryCopy(
String categoryName,
BuildContext context,
) {
final MorphologicalCategories? category =
MorphologicalCategoriesExtension.fromString(categoryName);
if (category == null) {
return null;
}
return category.getDisplayCopy(context);
}

View file

@ -1,18 +1,14 @@
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
class MorphFeatureDisplay extends StatelessWidget {
const MorphFeatureDisplay({
MorphFeatureDisplay({
super.key,
required String morphFeature,
required String morphTag,
}) : _morphFeature = morphFeature,
_morphTag = morphTag;
}) : _morphFeature = MorphFeaturesEnumExtension.fromString(morphFeature);
final String _morphFeature;
final String _morphTag;
final MorphFeaturesEnum _morphFeature;
@override
Widget build(BuildContext context) {
@ -20,22 +16,17 @@ class MorphFeatureDisplay extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 32.0,
height: 32.0,
width: 24.0,
height: 24.0,
child: MorphIcon(
morphFeature: _morphFeature,
morphTag: _morphTag,
morphTag: null,
),
),
const SizedBox(width: 10.0),
Text(
getGrammarCopy(
category: _morphFeature,
lemma: _morphTag,
context: context,
) ??
_morphTag,
style: Theme.of(context).textTheme.titleLarge,
_morphFeature.getDisplayCopy(context),
style: Theme.of(context).textTheme.titleMedium,
),
],
);

View file

@ -0,0 +1,224 @@
// ignore_for_file: constant_identifier_names
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:material_symbols_icons/symbols.dart';
enum MorphFeaturesEnum {
Pos,
AdvType,
Aspect,
Case,
ConjType,
Definite,
Degree,
Evident,
Foreign,
Gender,
Mood,
NounType,
NumForm,
NumType,
Number,
NumberPsor,
Person,
Polarity,
Polite,
Poss,
PrepCase,
PronType,
PunctSide,
PunctType,
Reflex,
Tense,
VerbForm,
VerbType,
Voice,
Unknown,
}
extension MorphFeaturesEnumExtension on MorphFeaturesEnum {
/// Convert enum to string
String toShortString() {
return toString().split('.').last.toLowerCase();
}
/// Convert string to enum
static MorphFeaturesEnum fromString(String category) {
final morph = MorphFeaturesEnum.values.firstWhereOrNull(
(e) =>
e.toShortString() ==
category.toLowerCase().replaceAll(RegExp(r'[,\[\]]'), ''),
);
if (morph == null) {
ErrorHandler.logError(
e: "Missing morphological category",
s: StackTrace.current,
data: {"category": category},
);
return MorphFeaturesEnum.Unknown;
}
return morph;
}
String getDisplayCopy(BuildContext context) {
switch (this) {
case MorphFeaturesEnum.Pos:
return L10n.of(context).grammarCopyPOS;
case MorphFeaturesEnum.AdvType:
return L10n.of(context).grammarCopyADVTYPE;
case MorphFeaturesEnum.Aspect:
return L10n.of(context).grammarCopyASPECT;
case MorphFeaturesEnum.Case:
return L10n.of(context).grammarCopyCASE;
case MorphFeaturesEnum.ConjType:
return L10n.of(context).grammarCopyCONJTYPE;
case MorphFeaturesEnum.Definite:
return L10n.of(context).grammarCopyDEFINITE;
case MorphFeaturesEnum.Degree:
return L10n.of(context).grammarCopyDEGREE;
case MorphFeaturesEnum.Evident:
return L10n.of(context).grammarCopyEVIDENT;
case MorphFeaturesEnum.Foreign:
return L10n.of(context).grammarCopyFOREIGN;
case MorphFeaturesEnum.Gender:
return L10n.of(context).grammarCopyGENDER;
case MorphFeaturesEnum.Mood:
return L10n.of(context).grammarCopyMOOD;
case MorphFeaturesEnum.NounType:
return L10n.of(context).grammarCopyNOUNTYPE;
case MorphFeaturesEnum.NumForm:
return L10n.of(context).grammarCopyNUMFORM;
case MorphFeaturesEnum.NumType:
return L10n.of(context).grammarCopyNUMTYPE;
case MorphFeaturesEnum.Number:
return L10n.of(context).grammarCopyNUMBER;
case MorphFeaturesEnum.NumberPsor:
return L10n.of(context).grammarCopyNUMBERPSOR;
case MorphFeaturesEnum.Person:
return L10n.of(context).grammarCopyPERSON;
case MorphFeaturesEnum.Polarity:
return L10n.of(context).grammarCopyPOLARITY;
case MorphFeaturesEnum.Polite:
return L10n.of(context).grammarCopyPOLITE;
case MorphFeaturesEnum.Poss:
return L10n.of(context).grammarCopyPOSS;
case MorphFeaturesEnum.PrepCase:
return L10n.of(context).grammarCopyPREPCASE;
case MorphFeaturesEnum.PronType:
return L10n.of(context).grammarCopyPRONTYPE;
case MorphFeaturesEnum.PunctSide:
return L10n.of(context).grammarCopyPUNCTSIDE;
case MorphFeaturesEnum.PunctType:
return L10n.of(context).grammarCopyPUNCTTYPE;
case MorphFeaturesEnum.Reflex:
return L10n.of(context).grammarCopyREFLEX;
case MorphFeaturesEnum.Tense:
return L10n.of(context).grammarCopyTENSE;
case MorphFeaturesEnum.VerbForm:
return L10n.of(context).grammarCopyVERBFORM;
case MorphFeaturesEnum.VerbType:
return L10n.of(context).grammarCopyVERBTYPE;
case MorphFeaturesEnum.Voice:
return L10n.of(context).grammarCopyVOICE;
case MorphFeaturesEnum.Unknown:
return L10n.of(context).grammarCopyUNKNOWN;
}
}
/// the subset of morphological categories that are important to practice for learning the language
/// by order of importance
static List<MorphFeaturesEnum> get eligibleForPractice => [
MorphFeaturesEnum.Pos,
MorphFeaturesEnum.Tense,
MorphFeaturesEnum.VerbForm,
MorphFeaturesEnum.VerbType,
MorphFeaturesEnum.Voice,
MorphFeaturesEnum.AdvType,
MorphFeaturesEnum.Aspect,
MorphFeaturesEnum.Case,
MorphFeaturesEnum.ConjType,
MorphFeaturesEnum.Definite,
MorphFeaturesEnum.Degree,
MorphFeaturesEnum.Evident,
MorphFeaturesEnum.Gender,
MorphFeaturesEnum.Mood,
MorphFeaturesEnum.NounType,
MorphFeaturesEnum.NumForm,
MorphFeaturesEnum.NumType,
MorphFeaturesEnum.Number,
MorphFeaturesEnum.NumberPsor,
MorphFeaturesEnum.Person,
MorphFeaturesEnum.Polarity,
MorphFeaturesEnum.Polite,
MorphFeaturesEnum.Poss,
MorphFeaturesEnum.PrepCase,
MorphFeaturesEnum.PronType,
MorphFeaturesEnum.Reflex,
];
bool get isEligibleForPractice {
return eligibleForPractice.contains(this);
}
IconData get fallbackIcon {
switch (this) {
case MorphFeaturesEnum.Number:
// google material 123 icon
return Icons.format_list_numbered;
case MorphFeaturesEnum.Gender:
return Icons.wc;
case MorphFeaturesEnum.Tense:
return Icons.access_time;
case MorphFeaturesEnum.Mood:
return Icons.mood;
case MorphFeaturesEnum.Person:
return Icons.person;
case MorphFeaturesEnum.Case:
return Icons.format_list_bulleted;
case MorphFeaturesEnum.Degree:
return Icons.trending_up;
case MorphFeaturesEnum.VerbForm:
return Icons.text_format;
case MorphFeaturesEnum.Voice:
return Icons.record_voice_over;
case MorphFeaturesEnum.Aspect:
return Icons.aspect_ratio;
case MorphFeaturesEnum.PronType:
return Icons.text_fields;
case MorphFeaturesEnum.NumType:
return Icons.format_list_numbered;
case MorphFeaturesEnum.Poss:
return Icons.account_balance;
case MorphFeaturesEnum.Reflex:
return Icons.refresh;
case MorphFeaturesEnum.Foreign:
return Icons.language;
case MorphFeaturesEnum.NounType:
return Symbols.abc;
case MorphFeaturesEnum.Pos:
return Symbols.toys_and_games;
case MorphFeaturesEnum.Polarity:
return Icons.swap_vert;
case MorphFeaturesEnum.Definite:
return Icons.check_circle_outline;
case MorphFeaturesEnum.PrepCase:
return Icons.location_on_outlined;
case MorphFeaturesEnum.ConjType:
return Icons.compare_arrows;
default:
return Icons.help_outline;
}
}
}
String? getMorphologicalCategoryCopy(
String categoryName,
BuildContext context,
) {
final MorphFeaturesEnum category =
MorphFeaturesEnumExtension.fromString(categoryName);
return category.getDisplayCopy(context);
}

View file

@ -1,37 +1,55 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/common/widgets/customized_svg.dart';
import 'package:fluffychat/pangea/morphs/get_icon_for_morph_feature.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/get_svg_link.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/utils/color_value.dart';
import 'package:flutter/material.dart';
class MorphIcon extends StatelessWidget {
const MorphIcon({
super.key,
required this.morphFeature,
required this.morphTag,
this.size,
this.showTooltip = false,
});
final String morphFeature;
final MorphFeaturesEnum morphFeature;
final String? morphTag;
final bool showTooltip;
final Size? size;
@override
Widget build(BuildContext context) {
// debugPrint("MorphIcon: morphFeature: $morphFeature, morphTag: $morphTag");
final ThemeData theme = Theme.of(context);
return CustomizedSvg(
svgUrl: getMorphSvgLink(
morphFeature: morphFeature,
morphTag: morphTag,
context: context,
return Tooltip(
message: morphTag == null
? morphFeature.getDisplayCopy(context)
: getGrammarCopy(
category: morphFeature.name,
lemma: morphTag!,
context: context,
),
triggerMode: TooltipTriggerMode.tap,
child: CustomizedSvg(
svgUrl: getMorphSvgLink(
morphFeature: morphFeature.name,
morphTag: morphTag,
context: context,
),
colorReplacements: theme.brightness == Brightness.dark
? {
"white": theme.cardColor.hexValue.toString(),
"black": "white",
}
: {},
errorIcon: Icon(morphFeature.fallbackIcon),
width: size?.width,
height: size?.height,
),
colorReplacements: theme.brightness == Brightness.dark
? {
"white": theme.cardColor.hexValue.toString(),
"black": "white",
}
: {},
errorIcon: Icon(getIconForMorphFeature(morphFeature)),
);
}
}

View file

@ -1,18 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:flutter/material.dart';
class MorphTagDisplay extends StatelessWidget {
const MorphTagDisplay({
super.key,
required String morphFeature,
required MorphFeaturesEnum morphFeature,
required String morphTag,
required this.textColor,
}) : _morphFeature = morphFeature;
}) : _morphFeature = morphFeature,
_morphTag = morphTag;
final String _morphFeature;
final MorphFeaturesEnum _morphFeature;
final String _morphTag;
final Color textColor;
@override
@ -22,20 +23,19 @@ class MorphTagDisplay extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(
width: 24.0,
height: 24.0,
child: MorphIcon(morphFeature: _morphFeature, morphTag: null),
width: 32.0,
height: 32.0,
child: MorphIcon(morphFeature: _morphFeature, morphTag: _morphTag),
),
const SizedBox(width: 10.0),
Text(
_morphFeature.toLowerCase() == "other"
? L10n.of(context).other
: ConstructTypeEnum.morph.getDisplayCopy(
_morphFeature,
context,
) ??
_morphFeature,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
getGrammarCopy(
category: _morphFeature.name,
lemma: _morphTag,
context: context,
) ??
_morphTag,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: textColor,
),
),

View file

@ -1,20 +1,27 @@
import 'package:flutter/material.dart';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
/// list ordered by priority
enum PartOfSpeechEnum {
//Content tokens
noun,
verb,
adj,
adv,
enum GrammarCopyPOS {
//Function tokens
sconj,
num,
verb,
affix,
part,
adj,
cconj,
punct,
adv,
aux,
space,
sym,
@ -22,19 +29,18 @@ enum GrammarCopyPOS {
pron,
adp,
propn,
noun,
intj,
x,
}
extension GrammarCopyPOSExtension on GrammarCopyPOS {
extension PartOfSpeechEnumExtensions on PartOfSpeechEnum {
/// Convert enum to string
String toShortString() {
return toString().split('.').last.toLowerCase();
}
GrammarCopyPOS? fromString(String categoryName) {
final pos = GrammarCopyPOS.values.firstWhereOrNull(
static PartOfSpeechEnum? fromString(String categoryName) {
final pos = PartOfSpeechEnum.values.firstWhereOrNull(
(pos) => pos.toShortString() == categoryName.toLowerCase(),
);
if (pos == null) {
@ -49,50 +55,103 @@ extension GrammarCopyPOSExtension on GrammarCopyPOS {
String getDisplayCopy(BuildContext context) {
switch (this) {
case GrammarCopyPOS.sconj:
case PartOfSpeechEnum.sconj:
return L10n.of(context).grammarCopyPOSsconj;
case GrammarCopyPOS.num:
case PartOfSpeechEnum.num:
return L10n.of(context).grammarCopyPOSnum;
case GrammarCopyPOS.verb:
case PartOfSpeechEnum.verb:
return L10n.of(context).grammarCopyPOSverb;
case GrammarCopyPOS.affix:
case PartOfSpeechEnum.affix:
return L10n.of(context).grammarCopyPOSaffix;
case GrammarCopyPOS.part:
case PartOfSpeechEnum.part:
return L10n.of(context).grammarCopyPOSpart;
case GrammarCopyPOS.adj:
case PartOfSpeechEnum.adj:
return L10n.of(context).grammarCopyPOSadj;
case GrammarCopyPOS.cconj:
case PartOfSpeechEnum.cconj:
return L10n.of(context).grammarCopyPOScconj;
case GrammarCopyPOS.punct:
case PartOfSpeechEnum.punct:
return L10n.of(context).grammarCopyPOSpunct;
case GrammarCopyPOS.adv:
case PartOfSpeechEnum.adv:
return L10n.of(context).grammarCopyPOSadv;
case GrammarCopyPOS.aux:
case PartOfSpeechEnum.aux:
return L10n.of(context).grammarCopyPOSaux;
case GrammarCopyPOS.space:
case PartOfSpeechEnum.space:
return L10n.of(context).grammarCopyPOSspace;
case GrammarCopyPOS.sym:
case PartOfSpeechEnum.sym:
return L10n.of(context).grammarCopyPOSsym;
case GrammarCopyPOS.det:
case PartOfSpeechEnum.det:
return L10n.of(context).grammarCopyPOSdet;
case GrammarCopyPOS.pron:
case PartOfSpeechEnum.pron:
return L10n.of(context).grammarCopyPOSpron;
case GrammarCopyPOS.adp:
case PartOfSpeechEnum.adp:
return L10n.of(context).grammarCopyPOSadp;
case GrammarCopyPOS.propn:
case PartOfSpeechEnum.propn:
return L10n.of(context).grammarCopyPOSpropn;
case GrammarCopyPOS.noun:
case PartOfSpeechEnum.noun:
return L10n.of(context).grammarCopyPOSnoun;
case GrammarCopyPOS.intj:
case PartOfSpeechEnum.intj:
return L10n.of(context).grammarCopyPOSintj;
case GrammarCopyPOS.x:
case PartOfSpeechEnum.x:
return L10n.of(context).grammarCopyPOSx;
}
}
bool get isContentWord => [
PartOfSpeechEnum.noun,
PartOfSpeechEnum.verb,
PartOfSpeechEnum.adj,
PartOfSpeechEnum.adv,
].contains(this);
bool get canBeDefined => [
PartOfSpeechEnum.noun,
PartOfSpeechEnum.verb,
PartOfSpeechEnum.adj,
PartOfSpeechEnum.adv,
PartOfSpeechEnum.propn,
PartOfSpeechEnum.intj,
PartOfSpeechEnum.det,
PartOfSpeechEnum.pron,
PartOfSpeechEnum.sconj,
PartOfSpeechEnum.cconj,
PartOfSpeechEnum.adp,
PartOfSpeechEnum.aux,
PartOfSpeechEnum.num,
].contains(this);
bool get canBeHeard => [
PartOfSpeechEnum.noun,
PartOfSpeechEnum.verb,
PartOfSpeechEnum.adj,
PartOfSpeechEnum.adv,
PartOfSpeechEnum.propn,
PartOfSpeechEnum.intj,
PartOfSpeechEnum.det,
PartOfSpeechEnum.pron,
PartOfSpeechEnum.sconj,
PartOfSpeechEnum.cconj,
PartOfSpeechEnum.adp,
PartOfSpeechEnum.aux,
PartOfSpeechEnum.num,
].contains(this);
bool eligibleForPractice(ActivityTypeEnum activityType) {
switch (activityType) {
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.morphId:
return canBeDefined;
case ActivityTypeEnum.wordFocusListening:
return canBeHeard;
default:
debugger(when: kDebugMode);
return false;
}
}
}
String? getVocabCategoryName(String category, BuildContext context) {
return GrammarCopyPOS.values
return PartOfSpeechEnum.values
.firstWhereOrNull((pos) => pos.toShortString() == category.toLowerCase())
?.getDisplayCopy(context);
}

View file

@ -1,10 +1,6 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.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/constructs/construct_identifier.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
enum ActivityTypeEnum {
wordMeaning,
@ -156,18 +152,22 @@ extension ActivityTypeExtension on ActivityTypeEnum {
}
}
/// Filters out constructs that are not relevant to the activity type
bool Function(ConstructIdentifier) get constructFilter {
ConstructUseTypeEnum? get incorrectUse {
switch (this) {
case ActivityTypeEnum.wordMeaning:
return ConstructUseTypeEnum.incPA;
case ActivityTypeEnum.wordFocusListening:
return ConstructUseTypeEnum.incWL;
case ActivityTypeEnum.hiddenWordListening:
return ConstructUseTypeEnum.incHWL;
case ActivityTypeEnum.lemmaId:
return ConstructUseTypeEnum.incL;
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.messageMeaning:
return (id) => id.type == ConstructTypeEnum.vocab;
return null;
case ActivityTypeEnum.morphId:
return (id) => id.type == ConstructTypeEnum.morph;
return ConstructUseTypeEnum.incM;
case ActivityTypeEnum.messageMeaning:
return ConstructUseTypeEnum.incMM;
}
}
@ -177,7 +177,7 @@ extension ActivityTypeExtension on ActivityTypeEnum {
return Icons.translate;
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
return Icons.hearing;
return Icons.volume_up;
case ActivityTypeEnum.lemmaId:
return Symbols.dictionary;
case ActivityTypeEnum.emoji:

View file

@ -1,17 +1,15 @@
import 'dart:developer';
import 'dart:math';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
class EmojiActivityGenerator {
Future<MessageActivityResponse> get(
MessageActivityRequest req,
@ -22,11 +20,16 @@ class EmojiActivityGenerator {
final PangeaToken token = req.targetTokens.first;
final List<String> emojis = await token.getEmojiChoices();
final tokenEmoji = token.getEmoji();
if (tokenEmoji != null && !emojis.contains(tokenEmoji)) {
final List<String> tokenEmojis = token.getEmoji();
//TODO : fix this or delete the file
if (tokenEmojis.isNotEmpty) {
final Random random = Random();
final int randomIndex = random.nextInt(emojis.length);
emojis[randomIndex] = tokenEmoji;
for (final emoji in tokenEmojis) {
final emojiIndex = emojis.indexOf(emoji);
if (emojiIndex != -1) continue;
final int randomIndex = random.nextInt(emojis.length);
emojis[randomIndex] = emoji;
}
}
// TODO - modify MultipleChoiceActivity flow to allow no correct answer

View file

@ -1,17 +1,15 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class LemmaActivityGenerator {
Future<MessageActivityResponse> get(

View file

@ -1,24 +1,20 @@
import 'dart:async';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/word_bank/vocab_bank_repo.dart';
import 'package:fluffychat/pangea/word_bank/vocab_request.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class LemmaMeaningActivityGenerator {
/// Cache whether a lemma has distractors for a given part of speech
static final Map<String, bool> _hasDistractorsCache = {};
static Timer? _cacheClearTimer;
Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
@ -38,7 +34,7 @@ class LemmaMeaningActivityGenerator {
userL1: req.userL1,
);
final res = await LemmaInfoRepo.get(lemmaDefReq, null, true);
final res = await LemmaInfoRepo.get(lemmaDefReq);
final choices = await getDistractorMeanings(lemmaDefReq, 3);
@ -64,40 +60,25 @@ class LemmaMeaningActivityGenerator {
);
}
static List<ConstructUses> eligibleDistractors(String lemma, String pos) {
final distractors =
MatrixState.pangeaController.getAnalytics.constructListModel
.constructList(type: ConstructTypeEnum.vocab)
.where(
(c) =>
c.lemma.isNotEmpty && // must not be empty strings
c.lemma.toLowerCase() !=
lemma.toLowerCase() && // must not be the lemma itself
c.category.toLowerCase() ==
pos.toLowerCase(), // must be same part of speech
)
.toList();
_hasDistractorsCache['${lemma.toLowerCase()}-${pos.toLowerCase()}'] =
distractors.isNotEmpty;
_cacheClearTimer ??= Timer.periodic(const Duration(minutes: 2), (Timer t) {
_hasDistractorsCache.clear();
});
return distractors;
}
/// From the cache, get a random set of cached definitions that are not for a specific lemma
static Future<List<String>> getDistractorMeanings(
LemmaInfoRequest req,
int count,
) async {
final eligible = eligibleDistractors(req.lemma, req.partOfSpeech);
eligible.shuffle();
final eligible = await VocabRepo.getSemanticallySimilarWords(
VocabRequest(
langCode: req.lemmaLang,
level: MatrixState
.pangeaController.userController.profile.userSettings.cefrLevel,
lemma: req.lemma,
pos: req.partOfSpeech,
count: count,
),
);
eligible.vocab.shuffle();
final List<ConstructUses> distractorConstructUses =
eligible.take(count).toList();
final List<ConstructIdentifier> distractorConstructUses =
eligible.vocab.take(count).toList();
final List<Future<LemmaInfoResponse>> futureDefs = [];
for (final construct in distractorConstructUses) {

View file

@ -1,13 +1,11 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
// includes feedback text and the bad activity model
class ActivityQualityFeedback {

View file

@ -0,0 +1,283 @@
import 'dart:developer';
import 'package:collection/collection.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/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart';
import 'package:flutter/foundation.dart';
class MessageAnalyticsEntry {
final DateTime createdAt = DateTime.now();
late final List<PangeaToken> _tokens;
final Map<ActivityTypeEnum, List<TargetTokensAndActivityType>>
_activityQueue = {};
final int _maxQueueLength = 5;
MessageAnalyticsEntry({
required List<PangeaToken> tokens,
required bool includeHiddenWordActivities,
required PangeaMessageEvent pangeaMessageEvent,
}) {
_tokens = tokens;
initialize();
}
void _pushQueue(TargetTokensAndActivityType entry) {
if (_activityQueue.containsKey(entry.activityType)) {
_activityQueue[entry.activityType]!.insert(0, entry);
} else {
_activityQueue[entry.activityType] = [entry];
}
// just in case we make a mistake and the queue gets too long
if (_activityQueue[entry.activityType]!.length > _maxQueueLength) {
debugger(when: kDebugMode);
_activityQueue[entry.activityType]!.removeRange(
_maxQueueLength,
_activityQueue.length,
);
}
}
void _filterActivityQueue(ActivityTypeEnum activityType) {
_activityQueue[activityType]?.clear();
}
void _clearAllQueue() {
_activityQueue.clear();
}
TargetTokensAndActivityType? nextActivity(ActivityTypeEnum a) =>
_activityQueue[a]?.firstOrNull;
bool get hasHiddenWordActivity =>
activities(ActivityTypeEnum.hiddenWordListening).isNotEmpty;
bool get hasMessageMeaningActivity =>
activities(ActivityTypeEnum.messageMeaning).isNotEmpty;
int get numActivities => _activityQueue.length;
List<TargetTokensAndActivityType> activities(ActivityTypeEnum a) =>
_activityQueue[a] ?? [];
// /// If there are more than 4 tokens that can be heard, we don't want to do word focus listening
// /// Otherwise, we don't have enough distractors
// bool get canDoWordFocusListening =>
// _tokens.where((t) => t.canBeHeard).length > 4;
/// On initialization, we pick which tokens to do activities on and what types of activities to do
void initialize() {
final eligibleTokens = _tokens.where((t) => t.lemma.saveVocab);
// EMOJI
// sort the tokens by the preference of them for an emoji activity
// order from least to most recent
// words that have never been used are counted as 1000 days
// we preference content words over function words by multiplying the days since last use by 2
// NOTE: for now, we put it at the end if it has no uses and basically just give them the answer
// later on, we may introduce an emoji activity that is easier than the current matching one
// i.e. we show them 3 good emojis and 1 bad one and ask them to pick the bad one
_activityQueue[ActivityTypeEnum.emoji] = eligibleTokens
.map(
(t) => TargetTokensAndActivityType(
tokens: [t],
activityType: ActivityTypeEnum.emoji,
),
)
.sorted(
(a, b) => a.tokens.first
.activityPriorityScore(ActivityTypeEnum.emoji, null)
.compareTo(
b.tokens.first
.activityPriorityScore(ActivityTypeEnum.emoji, null),
),
)
.take(_maxQueueLength)
.shuffled()
.toList();
// WORD MEANING
// make word meaning activities
// same as emojis for now
_activityQueue[ActivityTypeEnum.wordMeaning] = eligibleTokens
.map(
(t) => TargetTokensAndActivityType(
tokens: [t],
activityType: ActivityTypeEnum.wordMeaning,
),
)
.sorted(
(a, b) => a.tokens.first
.activityPriorityScore(ActivityTypeEnum.wordMeaning, null)
.compareTo(
b.tokens.first
.activityPriorityScore(ActivityTypeEnum.wordMeaning, null),
),
)
.take(_maxQueueLength)
.shuffled()
.toList();
// WORD FOCUS LISTENING
// make word focus listening activities
// same as emojis for now
_activityQueue[ActivityTypeEnum.wordFocusListening] = eligibleTokens
.map(
(t) => TargetTokensAndActivityType(
tokens: [t],
activityType: ActivityTypeEnum.wordFocusListening,
),
)
.sorted(
(a, b) => a.tokens.first
.activityPriorityScore(ActivityTypeEnum.wordFocusListening, null)
.compareTo(
b.tokens.first.activityPriorityScore(
ActivityTypeEnum.wordFocusListening,
null,
),
),
)
.take(_maxQueueLength)
.shuffled()
.toList();
// GRAMMAR
// build a list of TargetTokensAndActivityType for all tokens and all features in the message
// limits to _maxQueueLength activities and only one per token
final List<TargetTokensAndActivityType> candidates = eligibleTokens.expand(
(t) {
return t.morphsBasicallyEligibleForPracticeByPriority.map(
(m) => TargetTokensAndActivityType(
tokens: [t],
activityType: ActivityTypeEnum.morphId,
morphFeature: MorphFeaturesEnumExtension.fromString(m.category),
),
);
},
).sorted(
(a, b) => a.tokens.first
.activityPriorityScore(
ActivityTypeEnum.morphId,
a.morphFeature!.name,
)
.compareTo(
b.tokens.first.activityPriorityScore(
ActivityTypeEnum.morphId,
b.morphFeature!.name,
),
),
);
//pick from the top 5, only including one per token
_activityQueue[ActivityTypeEnum.morphId] = [];
for (final candidate in candidates) {
if (_activityQueue[ActivityTypeEnum.morphId]!.length >= _maxQueueLength) {
break;
}
if (_activityQueue[ActivityTypeEnum.morphId]?.any(
(entry) => entry.tokens.contains(candidate.tokens.first),
) ==
false) {
_activityQueue[ActivityTypeEnum.morphId]?.add(candidate);
}
}
}
bool hasActivity(
ActivityTypeEnum a,
PangeaToken t, [
MorphFeaturesEnum? morph,
]) =>
_activityQueue[a]?.any(
(entry) =>
entry.tokens.contains(t) &&
(morph == null || entry.morphFeature == morph),
) ==
true;
/// Add a message meaning activity to the front of the queue
/// And limits to _maxQueueLength activities
void addMessageMeaningActivity() {
final entry = TargetTokensAndActivityType(
tokens: _tokens,
activityType: ActivityTypeEnum.messageMeaning,
);
_pushQueue(entry);
}
void onActivityComplete(ActivityTypeEnum a, PangeaToken? token) {
_activityQueue[a]
?.removeWhere((entry) => token == null || entry.tokens.contains(token));
}
void exitPracticeFlow() => _activityQueue.clear();
void revealAllTokens() =>
_activityQueue[ActivityTypeEnum.hiddenWordListening]?.clear();
bool isTokenInHiddenWordActivity(PangeaToken token) =>
_activityQueue[ActivityTypeEnum.hiddenWordListening]?.isNotEmpty ?? false;
}
/// computes TokenWithXP for given a pangeaMessageEvent and caches the result, according to the full text of the message
/// listens for analytics updates and updates the cache accordingly
class MessageAnalyticsController {
static final Map<String, MessageAnalyticsEntry> _cache = {};
void dispose() {
_cache.clear();
}
// if over 300, remove oldest 5 entries by createdAt
static void clean() {
if (_cache.length > 300) {
final sortedEntries = _cache.entries.toList()
..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
for (var i = 0; i < 5; i++) {
_cache.remove(sortedEntries[i].key);
}
}
}
static String _key(List<PangeaToken> tokens) =>
PangeaToken.reconstructText(tokens);
static MessageAnalyticsEntry? get(
List<PangeaToken> tokens,
PangeaMessageEvent pangeaMessageEvent,
) {
final String key = _key(tokens);
final entry = _cache[key];
// if cache is older than 1 day, then remove and recompute
if (entry != null &&
DateTime.now().difference(entry.createdAt).inDays > 1) {
_cache.remove(key);
}
if (entry != null) {
return entry;
}
final bool includeHiddenWordActivities = !pangeaMessageEvent.ownMessage &&
pangeaMessageEvent.messageDisplayRepresentation?.tokens != null &&
pangeaMessageEvent.messageDisplayLangIsL2 &&
!pangeaMessageEvent.event.isRichMessage;
_cache[key] = MessageAnalyticsEntry(
tokens: tokens,
includeHiddenWordActivities: includeHiddenWordActivities,
pangeaMessageEvent: pangeaMessageEvent,
);
clean();
return _cache[key];
}
}

View file

@ -1,19 +1,17 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/morphs/morph_categories_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
typedef MorphActivitySequence = Map<String, POSActivitySequence>;

View file

@ -1,13 +1,11 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
class ActivityContent {
final String question;

View file

@ -1,19 +1,17 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/morphs/morph_categories_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_display_instructions_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_display_instructions_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
class CandidateMessage {
final String msgId;

View file

@ -5,13 +5,12 @@
import 'dart:developer';
import 'package:flutter/foundation.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/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
class PracticeActivityRecordModel {
final String? question;

View file

@ -2,12 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
@ -15,15 +9,19 @@ import 'package:fluffychat/pangea/common/network/urls.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/emoji_activity_generator.dart';
import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.dart';
import 'package:fluffychat/pangea/practice_activities/lemma_meaning_activity_generator.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/repo/emoji_activity_generator.dart';
import 'package:fluffychat/pangea/toolbar/repo/lemma_activity_generator.dart';
import 'package:fluffychat/pangea/toolbar/repo/lemma_meaning_activity_generator.dart';
import 'package:fluffychat/pangea/toolbar/repo/morph_activity_generator.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:http/http.dart';
import 'package:matrix/matrix.dart';
/// Represents an item in the completion cache.
class _RequestCacheItem {

View file

@ -0,0 +1,48 @@
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:flutter/foundation.dart';
/// Picks which tokens to do activities on and what types of activities to do
/// Caches result so that we don't have to recompute it
/// Most importantly, we can't do this in the state of a message widget because the state is disposed of and recreated
/// If we decided that the first token should have a hidden word listening, we need to remember that
/// Otherwise, the user might leave the chat, return, and see a different word hidden
class TargetTokensAndActivityType {
/// this is the tokens involved in the activity
/// for most, this will be a single token
final List<PangeaToken> tokens;
final ActivityTypeEnum activityType;
// this is only defined for morphId activities
final MorphFeaturesEnum? morphFeature;
TargetTokensAndActivityType({
required this.tokens,
required this.activityType,
this.morphFeature,
}) {
if (ActivityTypeEnum.hiddenWordListening != activityType &&
tokens.length != 1) {
throw Exception("Only hiddenWordListening can have multiple tokens");
}
if (ActivityTypeEnum.morphId == activityType && morphFeature == null) {
throw Exception("morphFeature must be defined for morphId activities");
}
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is TargetTokensAndActivityType &&
listEquals(other.tokens, tokens) &&
other.activityType == activityType &&
other.morphFeature == morphFeature;
}
@override
int get hashCode =>
tokens.hashCode ^ activityType.hashCode ^ morphFeature.hashCode;
}

View file

@ -3,10 +3,10 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
final wordMeaningStaticPracticeActivityModel = MessageActivityResponse(
activity: PracticeActivityModel(

View file

@ -1,10 +1,15 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/config/app_config.dart';
enum MessageMode {
practiceActivity,
@ -16,7 +21,7 @@ enum MessageMode {
// wordZoomSpeechToText,
messageMeaning,
messageTextToSpeech,
listening,
messageSpeechToText,
messageTranslation,
@ -28,9 +33,9 @@ extension MessageModeExtension on MessageMode {
IconData get icon {
switch (this) {
case MessageMode.messageTranslation:
return Icons.g_translate;
case MessageMode.messageTextToSpeech:
return Symbols.text_to_speech;
return Icons.translate;
case MessageMode.listening:
return Icons.volume_up;
case MessageMode.messageSpeechToText:
return Symbols.speech_to_text;
case MessageMode.practiceActivity:
@ -43,7 +48,7 @@ extension MessageModeExtension on MessageMode {
case MessageMode.messageMeaning:
return Icons.star;
case MessageMode.wordEmoji:
return Icons.emoji_emotions;
return Icons.add_reaction_outlined;
case MessageMode.wordMorph:
return Symbols.toys_and_games;
}
@ -53,7 +58,7 @@ extension MessageModeExtension on MessageMode {
switch (this) {
case MessageMode.messageTranslation:
return L10n.of(context).translations;
case MessageMode.messageTextToSpeech:
case MessageMode.listening:
return L10n.of(context).messageAudio;
case MessageMode.messageSpeechToText:
return L10n.of(context).speechToTextTooltip;
@ -69,7 +74,7 @@ extension MessageModeExtension on MessageMode {
case MessageMode.wordEmoji:
return "Emoji";
case MessageMode.wordMorph:
return "Morph";
return "Grammar";
case MessageMode.wordMeaning:
return "Meaning";
}
@ -79,8 +84,8 @@ extension MessageModeExtension on MessageMode {
switch (this) {
case MessageMode.messageTranslation:
return L10n.of(context).translationTooltip;
case MessageMode.messageTextToSpeech:
return L10n.of(context).audioTooltip;
case MessageMode.listening:
return L10n.of(context).listen;
case MessageMode.messageSpeechToText:
return L10n.of(context).speechToTextTooltip;
case MessageMode.practiceActivity:
@ -95,42 +100,64 @@ extension MessageModeExtension on MessageMode {
case MessageMode.wordEmoji:
return "Emoji";
case MessageMode.wordMorph:
return "Morph";
return "Grammar";
case MessageMode.wordMeaning:
return "Meaning";
}
}
InstructionsEnum? get instructionsEnum {
switch (this) {
case MessageMode.wordMorph:
return InstructionsEnum.chooseMorphs;
case MessageMode.messageSpeechToText:
return InstructionsEnum.speechToText;
case MessageMode.wordMeaning:
return InstructionsEnum.chooseLemmaMeaning;
case MessageMode.listening:
return InstructionsEnum.chooseWordAudio;
case MessageMode.wordEmoji:
return InstructionsEnum.chooseEmoji;
case MessageMode.noneSelected:
return InstructionsEnum.readingAssistanceOverview;
case MessageMode.messageTranslation:
case MessageMode.messageMeaning:
case MessageMode.wordZoom:
case MessageMode.practiceActivity:
return null;
}
}
double get pointOnBar {
switch (this) {
case MessageMode.practiceActivity:
return 0;
case MessageMode.messageTextToSpeech:
return 0.35;
case MessageMode.messageTranslation:
return 0.64;
case MessageMode.messageMeaning:
// case MessageMode.stats:
// return 1;
case MessageMode.noneSelected:
return 1;
case MessageMode.wordMorph:
return 0.7;
case MessageMode.wordMeaning:
return 0.5;
case MessageMode.listening:
return 0.3;
case MessageMode.messageTranslation:
case MessageMode.messageSpeechToText:
case MessageMode.wordZoom:
case MessageMode.noneSelected:
case MessageMode.wordEmoji:
case MessageMode.wordMorph:
case MessageMode.wordMeaning:
case MessageMode.messageMeaning:
case MessageMode.practiceActivity:
return 0;
}
}
bool isUnlocked(
double proportionOfActivitiesCompleted,
bool totallyDone,
MessageOverlayController overlayController,
) {
switch (this) {
case MessageMode.messageTranslation:
case MessageMode.messageTextToSpeech:
return proportionOfActivitiesCompleted >= pointOnBar || totallyDone;
return overlayController.isTranslationUnlocked;
case MessageMode.practiceActivity:
return !totallyDone;
case MessageMode.listening:
case MessageMode.messageSpeechToText:
case MessageMode.messageMeaning:
case MessageMode.wordZoom:
@ -144,28 +171,40 @@ extension MessageModeExtension on MessageMode {
bool get showButton => this != MessageMode.practiceActivity;
bool isModeDone(MessageOverlayController overlayController) {
switch (this) {
case MessageMode.messageTranslation:
return overlayController.isTotallyDone;
case MessageMode.listening:
return overlayController.isListeningDone;
case MessageMode.wordEmoji:
return overlayController.isEmojiDone;
case MessageMode.wordMorph:
return overlayController.isMorphDone;
case MessageMode.wordMeaning:
return overlayController.isMeaningDone;
default:
return false;
}
}
Color iconButtonColor(
BuildContext context,
MessageMode currentMode,
double proportionOfActivitiesUnlocked,
bool totallyDone,
MessageOverlayController overlayController,
) {
if (this == MessageMode.practiceActivity && totallyDone) {
if (overlayController.isTotallyDone) {
return AppConfig.gold;
}
//locked
if (!isUnlocked(proportionOfActivitiesUnlocked, totallyDone)) {
if (!isUnlocked(overlayController)) {
return barAndLockedButtonColor(context);
}
//unlocked and active
if (this == currentMode) {
return totallyDone ? AppConfig.gold : AppConfig.primaryColorLight;
}
//unlocked and inactive
return Theme.of(context).colorScheme.primaryContainer;
//unlocked
return isModeDone(overlayController)
? AppConfig.gold
: Theme.of(context).colorScheme.primaryContainer;
}
static Color barAndLockedButtonColor(BuildContext context) {
@ -173,4 +212,172 @@ extension MessageModeExtension on MessageMode {
? Colors.grey[800]!
: Colors.grey[200]!;
}
ActivityTypeEnum? get associatedActivityType {
switch (this) {
case MessageMode.wordMeaning:
return ActivityTypeEnum.wordMeaning;
case MessageMode.listening:
return ActivityTypeEnum.wordFocusListening;
case MessageMode.wordEmoji:
return ActivityTypeEnum.emoji;
case MessageMode.wordMorph:
return ActivityTypeEnum.morphId;
case MessageMode.noneSelected:
case MessageMode.messageMeaning:
case MessageMode.messageTranslation:
case MessageMode.wordZoom:
case MessageMode.messageSpeechToText:
case MessageMode.practiceActivity:
return null;
}
}
/// returns a nullable string of the current level of the message
/// if string is null, then user has completed all levels
/// should be resolvable into a part of speech or morph feature using fromString
/// of the respective enum, PartOfSpeechEnum or MorphFeatureEnum
String? currentChoiceMode(
MessageOverlayController overlayController,
PangeaMessageEvent pangeaMessage,
) {
switch (this) {
case MessageMode.wordMeaning:
case MessageMode.listening:
case MessageMode.wordEmoji:
// get the pos with some tokens left to practice, from most to least important for learning
return pangeaMessage.messageDisplayRepresentation!
.posSetToPractice(associatedActivityType!)
.firstWhereOrNull(
(pos) => pangeaMessage.messageDisplayRepresentation!.tokens!.any(
(t) => t.vocabConstructID.isActivityProbablyLevelAppropriate(
associatedActivityType!,
t.text.content,
),
),
)
?.name;
case MessageMode.wordMorph:
// get the morph feature with some tokens left to practice, from most to least important for learning
return pangeaMessage
.messageDisplayRepresentation!.morphFeatureSetToPractice
.firstWhereOrNull(
(feature) =>
pangeaMessage.messageDisplayRepresentation!.tokens!.any((t) {
final String? morphTag = t.getMorphTag(feature.name);
if (morphTag == null) {
return false;
}
return ConstructIdentifier(
lemma: morphTag,
type: ConstructTypeEnum.morph,
category: feature.name,
).isActivityProbablyLevelAppropriate(
associatedActivityType!,
t.text.content,
);
}),
)
?.name;
case MessageMode.noneSelected:
case MessageMode.messageMeaning:
case MessageMode.messageTranslation:
case MessageMode.wordZoom:
case MessageMode.messageSpeechToText:
case MessageMode.practiceActivity:
return null;
}
// final feature = MorphFeaturesEnumExtension.fromString(overlayController);
// if (feature != null) {
// for (int i; i < pangeaMessage.messageDisplayRepresentation!.morphFeatureSetToPractice.length; i++) {
// if (pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature).isNotEmpty ?? false) {
// return i;
// }
// }
// for (final feature in pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature)) ?? []) {
// if (pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature).isNotEmpty ?? false) {
// return feature.index;
// }
// }
// }
}
// List<MessageModeChoiceLevelWidget> messageModeChoiceLevel(
// MessageOverlayController overlayController,
// PangeaMessageEvent pangeaMessage,
// ) {
// switch (this) {
// case MessageMode.wordMorph:
// final morphFeatureSet = pangeaMessage
// .messageDisplayRepresentation?.morphFeatureSetToPractice;
// if (morphFeatureSet == null) {
// debugger(when: kDebugMode);
// return [];
// }
// // sort by the list of priority of parts of speech, defined by their order in the enum
// morphFeatureSet.toList().sort((a, b) => a.index.compareTo(b.index));
// debugPrint(
// "morphFeatureSet: ${morphFeatureSet.map((e) => e.name).toList()}",
// );
// return morphFeatureSet
// .map(
// (feature) => MessageModeChoiceLevelWidget(
// overlayController: overlayController,
// pangeaMessageEvent: pangeaMessage,
// morphFeature: feature,
// ),
// )
// .toList();
// case MessageMode.noneSelected:
// case MessageMode.messageMeaning:
// case MessageMode.messageTranslation:
// case MessageMode.messageTextToSpeech:
// case MessageMode.messageSpeechToText:
// case MessageMode.practiceActivity:
// case MessageMode.wordZoom:
// case MessageMode.wordMeaning:
// case MessageMode.wordEmoji:
// if (associatedActivityType == null) {
// debugger(when: kDebugMode);
// return [];
// }
// final posSet = pangeaMessage.messageDisplayRepresentation
// ?.posSetToPractice(associatedActivityType!);
// if (posSet == null) {
// debugger(when: kDebugMode);
// return [];
// }
// // sort by the list of priority of parts of speech, defined by their order in the enum
// posSet.toList().sort((a, b) => a.index.compareTo(b.index));
// debugPrint("posSet: ${posSet.map((e) => e.name).toList()}");
// final widgets = posSet
// .map(
// (pos) => MessageModeChoiceLevelWidget(
// partOfSpeech: pos,
// overlayController: overlayController,
// pangeaMessageEvent: pangeaMessage,
// ),
// )
// .toList();
// return widgets;
// }
// }
}

View file

@ -1,12 +1,11 @@
import 'dart:developer';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_record_event.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_record_event.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import '../../events/constants/pangea_event_types.dart';
class PracticeActivityEvent {

View file

@ -1,7 +1,7 @@
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_record_model.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_record_model.dart';
import '../../events/constants/pangea_event_types.dart';
class PracticeActivityRecordEvent {

View file

@ -0,0 +1,20 @@
import 'package:fluffychat/pangea/constructs/construct_form.dart';
class MatchFeedback {
ConstructForm form;
bool isCorrect;
MatchFeedback({required this.form, required this.isCorrect});
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is MatchFeedback &&
other.form == form &&
other.isCorrect == isCorrect;
}
@override
int get hashCode => form.hashCode ^ isCorrect.hashCode;
}

View file

@ -1,155 +0,0 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_emojis.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_emoji_choice_item.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class MessageEmojiChoice extends StatelessWidget {
final List<PangeaToken>? tokens;
final ChatController controller;
final MessageOverlayController overlayController;
const MessageEmojiChoice({
super.key,
required this.tokens,
required this.controller,
required this.overlayController,
});
Future<void> redactReaction(BuildContext context, String emoji) {
if (!context.mounted) {
return Future.value();
}
final evt = allReactionEvents.firstWhereOrNull(
(e) =>
e.senderId == e.room.client.userID &&
e.content.tryGetMap('m.relates_to')?['key'] == emoji,
);
if (evt != null) {
return showFutureLoadingDialog(
context: context,
future: () => evt.redactEvent(),
);
}
return Future.value();
}
Iterable<Event> get allReactionEvents => controller.selectedEvents.first
.aggregatedEvents(
controller.timeline!,
RelationshipTypes.reaction,
)
.where(
(event) =>
event.senderId == event.room.client.userID &&
event.type == 'm.reaction',
);
bool alreadyInReactions(String emoji) {
for (final event in allReactionEvents) {
try {
if (event.content.tryGetMap('m.relates_to')!['key'] == emoji) {
return true;
}
} catch (_) {}
}
return false;
}
void onDoubleTapOrLongPress(BuildContext context, String emoji) {
if (alreadyInReactions(emoji)) {
redactReaction(context, emoji);
} else {
controller.sendEmojiAction(emoji);
}
}
List<Widget> standardEmojiChoices(BuildContext context) => AppEmojis.emojis
.map(
(emoji) => MessageEmojiChoiceItem(
content: emoji,
onTap: () => alreadyInReactions(emoji)
? redactReaction(context, emoji)
: controller.sendEmojiAction(emoji),
isSelected: false,
onDoubleTap: () => onDoubleTapOrLongPress(context, emoji),
onLongPress: () => onDoubleTapOrLongPress(context, emoji),
greenHighlight: false,
),
)
.toList();
List<Widget> perTokenEmoji(BuildContext context) =>
tokens!.where((token) => token.lemma.saveVocab).map((token) {
final bool greenHighlight = token.shouldDoActivity(
a: ActivityTypeEnum.wordMeaning,
feature: null,
tag: null,
);
if (!token.lemma.saveVocab) {
return MessageEmojiChoiceItem(
content: token.text.content,
onTap: () => {},
isSelected: overlayController.isTokenSelected(token),
onDoubleTap: null,
onLongPress: null,
greenHighlight: greenHighlight,
);
}
final emoji = token.getEmoji();
if (emoji == null) {
return MessageEmojiChoiceItem(
topContent: token.vocabConstruct.constructLevel.icon(),
content: token.text.content,
onTap: () => overlayController.onClickOverlayMessageToken(token),
onDoubleTap: null,
onLongPress: null,
isSelected: overlayController.isTokenSelected(token),
contentOpacity: 0.1,
greenHighlight: greenHighlight,
);
}
return MessageEmojiChoiceItem(
topContent: Text(
emoji,
style: const TextStyle(fontSize: 24),
),
content: token.text.content,
onTap: () => overlayController.onClickOverlayMessageToken(token),
onDoubleTap: () => onDoubleTapOrLongPress(context, emoji),
onLongPress: () => onDoubleTapOrLongPress(context, emoji),
isSelected: overlayController.isTokenSelected(token),
greenHighlight: greenHighlight,
);
}).toList();
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
scrollDirection: Axis.vertical,
child: Wrap(
alignment: WrapAlignment.center,
// spacing: 8.0, // Adjust spacing between items
runSpacing: 0.0, // Adjust spacing between rows
children: tokens == null ||
tokens!.isEmpty ||
!(overlayController
.pangeaMessageEvent?.messageDisplayLangIsL2 ??
false)
? standardEmojiChoices(context)
: perTokenEmoji(context),
),
);
}
}

View file

@ -1,6 +1,13 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
const Size emojiButtonSize = Size(60, 60);
BoxDecoration emojiButtonDecoration = BoxDecoration(
color: Colors.transparent,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
);
class MessageEmojiChoiceItem extends StatefulWidget {
const MessageEmojiChoiceItem({
@ -13,7 +20,8 @@ class MessageEmojiChoiceItem extends StatefulWidget {
this.onLongPress,
required this.isSelected,
this.contentOpacity = 1.0,
required this.greenHighlight,
required this.isGold,
required this.token,
});
final Widget? topContent;
@ -24,7 +32,8 @@ class MessageEmojiChoiceItem extends StatefulWidget {
final bool isSelected;
final double textSize;
final double contentOpacity;
final bool greenHighlight;
final PangeaToken? token;
final bool? isGold;
@override
MessageEmojiChoiceItemState createState() => MessageEmojiChoiceItemState();
@ -33,8 +42,56 @@ class MessageEmojiChoiceItem extends StatefulWidget {
class MessageEmojiChoiceItemState extends State<MessageEmojiChoiceItem> {
bool _isHovered = false;
@override
didUpdateWidget(MessageEmojiChoiceItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isSelected != widget.isSelected ||
oldWidget.isGold != widget.isGold) {
setState(() {});
}
}
Color get color {
if (widget.isSelected) {
debugPrint('widget.isGold: ${widget.isGold}');
if (widget.isGold == null) {
return AppConfig.primaryColor.withAlpha((0.4 * 255).toInt());
} else {
return widget.isGold!
? AppConfig.success.withAlpha((0.4 * 255).toInt())
: AppConfig.warning.withAlpha((0.4 * 255).toInt());
}
}
if (_isHovered) {
return AppConfig.primaryColor.withAlpha((0.2 * 255).toInt());
}
return Colors.transparent;
}
@override
Widget build(BuildContext context) {
<<<<<<< HEAD
return Opacity(
opacity: widget.contentOpacity,
child: MouseRegion(
onEnter: (_) => setState(() => _isHovered = true),
onExit: (_) => setState(() => _isHovered = false),
child: InkWell(
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
onTap: widget.onTap,
onLongPress: widget.onLongPress,
child: Container(
height: emojiButtonSize.height,
width: emojiButtonSize.width,
alignment: Alignment.center,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
child: Text(
widget.content,
style: TextStyle(fontSize: widget.textSize - 2),
=======
return IntrinsicWidth(
child: Align(
alignment: Alignment.center,
@ -80,6 +137,7 @@ class MessageEmojiChoiceItemState extends State<MessageEmojiChoiceItem> {
],
),
),
>>>>>>> main
),
),
),

View file

@ -0,0 +1,120 @@
import 'dart:developer';
import 'package:fluffychat/pangea/constructs/construct_form.dart';
import 'package:fluffychat/pangea/message_token_text/message_token_button.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_match_activity_item.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MessageMatchActivity extends StatelessWidget {
final MessageOverlayController overlayController;
const MessageMatchActivity({
super.key,
required this.overlayController,
});
ActivityTypeEnum? get activityType =>
overlayController.toolbarMode.associatedActivityType;
List<String> choices(TargetTokensAndActivityType a) {
switch (activityType) {
case ActivityTypeEnum.emoji:
return overlayController
.messageLemmaInfos![a.tokens.first.vocabConstructID]!.emoji
.take(maxEmojisPerLemma)
.toList();
case ActivityTypeEnum.wordMeaning:
return [
overlayController
.messageLemmaInfos![a.tokens.first.vocabConstructID]!.meaning,
];
case ActivityTypeEnum.wordFocusListening:
return [a.tokens.first.text.content];
default:
debugger(when: kDebugMode);
return [];
}
}
Widget choiceDisplayContent(TargetTokensAndActivityType a, String choice) {
switch (activityType) {
case ActivityTypeEnum.emoji:
return Text(
choice,
style: const TextStyle(fontSize: 26),
);
case ActivityTypeEnum.wordMeaning:
return Text(
choice,
style: const TextStyle(fontSize: 26),
);
case ActivityTypeEnum.wordFocusListening:
return const Icon(
Icons.volume_up,
size: 26,
);
default:
debugger(when: kDebugMode);
return const SizedBox();
}
}
@override
Widget build(BuildContext context) {
if (overlayController.messageAnalyticsEntry == null ||
overlayController.messageLemmaInfos == null ||
activityType == null) {
debugger(when: kDebugMode);
return const SizedBox();
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
spacing: 16.0,
children: [
if (overlayController.toolbarMode == MessageMode.listening &&
overlayController.pangeaMessageEvent != null)
MessageAudioCard(
messageEvent: overlayController.pangeaMessageEvent!,
overlayController: overlayController,
setIsPlayingAudio: overlayController.setIsPlayingAudio,
),
Wrap(
alignment: WrapAlignment.center,
spacing: 8.0, // Adjust spacing between items
runSpacing: 8.0, // Adjust spacing between rows
children: overlayController.messageAnalyticsEntry!
.activities(activityType!)
.expand(
(TargetTokensAndActivityType a) {
return choices(a).map(
(choice) => MessageMatchActivityItem(
constructForm: ConstructForm(
choice,
a.tokens.first.vocabConstructID,
),
content: choiceDisplayContent(a, choice),
audioContent:
overlayController.toolbarMode == MessageMode.listening
? a.tokens.first.text.content
: null,
overlayController: overlayController,
fixedSize: a.activityType == ActivityTypeEnum.wordMeaning
? null
: 60,
),
);
},
).toList(),
),
],
);
}
}

View file

@ -0,0 +1,165 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_form.dart';
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/match_feedback_model.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class MessageMatchActivityItem extends StatefulWidget {
const MessageMatchActivityItem({
super.key,
required this.content,
required this.constructForm,
this.audioContent,
required this.overlayController,
required this.fixedSize,
});
final Widget content;
final ConstructForm constructForm;
final String? audioContent;
final MessageOverlayController overlayController;
final double? fixedSize;
@override
MessageMatchActivityItemState createState() =>
MessageMatchActivityItemState();
}
class MessageMatchActivityItemState extends State<MessageMatchActivityItem> {
bool _isHovered = false;
bool _isPlaying = false;
TtsController get tts =>
widget.overlayController.widget.chatController.choreographer.tts;
bool get isSelected =>
widget.overlayController.selectedChoice == widget.constructForm;
Future<void> play() async {
if (widget.audioContent == null) {
return;
}
if (_isPlaying) {
await tts.stop();
if (mounted) {
setState(() => _isPlaying = false);
}
} else {
if (mounted) {
setState(() => _isPlaying = true);
}
try {
await tts.tryToSpeak(
widget.audioContent!,
context,
targetID: 'word-audio-button',
);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
data: {"text": widget.audioContent},
);
} finally {
if (mounted) {
setState(() => _isPlaying = false);
}
}
}
}
MatchFeedback? get wasChosen => widget.overlayController.feedbackStates
.firstWhereOrNull((e) => e.form == widget.constructForm);
Color color(BuildContext context) {
final feedback = wasChosen;
if (feedback != null) {
return feedback.isCorrect ? AppConfig.success : AppConfig.warning;
}
if (isSelected) {
return AppConfig.primaryColor;
}
if (_isHovered) {
return Theme.of(context).colorScheme.primary;
}
return Colors.transparent;
}
@override
didUpdateWidget(MessageMatchActivityItem oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.overlayController.selectedChoice !=
widget.overlayController.selectedChoice ||
oldWidget.overlayController.selectedToken !=
widget.overlayController.selectedToken ||
widget.overlayController.feedbackStates
.any((e) => e.form == widget.constructForm) !=
oldWidget.overlayController.feedbackStates
.any((e) => e.form == widget.constructForm)) {
setState(() {});
}
}
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,
),
);
}
@override
Widget build(BuildContext context) {
return LongPressDraggable<ConstructForm>(
data: widget.constructForm,
feedback: content(context),
delay: const Duration(milliseconds: 100),
onDragStarted: () {
widget.overlayController.onChoiceSelect(widget.constructForm, true);
},
// onDragCompleted: () {
// debugger(when: kDebugMode);
// },
// onDragEnd: (details) {
// // debugger(when: kDebugMode);
// },
child: InkWell(
onHover: (isHovered) => setState(() => _isHovered = isHovered),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
onTap: () {
play();
widget.overlayController.onChoiceSelect(widget.constructForm);
},
child: content(context),
),
);
}
}

View file

@ -0,0 +1,232 @@
import 'dart:developer';
import 'package:collection/collection.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/choreographer/widgets/choice_array.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.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_model.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:fluffychat/pangea/morphs/morph_repo.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice_item.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/morphs/morphological_center_widget.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
// this widget will handle the content of the input bar when mode == MessageMode.wordMorph
// if initializing, with a selectedToken then we should show an activity if one is available
// in this case, we'll set selectedMorph to the first morph available for the selected token
// if initializing with a selectedMorph then we should show the first activity available for that morph
// if no activity available for that morph, then we should just show the details of the feature and tag
// the details of a morph will allow the user to edit the morphological tag of that feature.
const int numberOfMorphDistractors = 3;
class MessageMorphInputBarContent extends StatefulWidget {
final MessageOverlayController overlayController;
final PangeaMessageEvent pangeaMessageEvent;
const MessageMorphInputBarContent({
super.key,
required this.overlayController,
required this.pangeaMessageEvent,
});
@override
MessageMorphInputBarContentState createState() =>
MessageMorphInputBarContentState();
}
class MessageMorphInputBarContentState
extends State<MessageMorphInputBarContent> {
// bool initialized = false;
String? selectedTag;
// @override
// void initState() {
// super.initState();
// }
MessageOverlayController get overlay => widget.overlayController;
PangeaToken? get token => overlay.selectedToken;
MorphFeaturesEnum? get morph => overlay.selectedMorph;
// void init() async {
// initialized = false;
// setState(() {});
// if (token != null && morph != null) {
// morphChoices = MorphsRepo.cached.getDisplayTags(morph);
// }
// initialized = true;
// setState(() {});
// }
@override
void didUpdateWidget(covariant MessageMorphInputBarContent oldWidget) {
if (morph != oldWidget.overlayController.selectedMorph ||
token != oldWidget.overlayController.selectedToken) {
selectedTag = null;
setState(() {});
}
super.didUpdateWidget(oldWidget);
}
List<String>? get choices {
if (morph == null ||
token == null ||
overlay.messageAnalyticsEntry
?.hasActivity(ActivityTypeEnum.morphId, token!, morph) ==
false) {
return null;
}
final tag = token!.getMorphTag(morph!.name)!;
return MorphsRepo.cached
.getDisplayTags(morph!.name)
.where((other) => other.toLowerCase() != tag.toLowerCase())
.toList()
.take(numberOfMorphDistractors)
.toList() +
[tag];
}
bool isCorrect(String tag) => token?.getMorphTag(morph!.name) == tag;
Future<void> onActivityChoice(
String choice,
) async {
if (token == null || morph == null) {
ErrorHandler.logError(
m: "Token or morph is null in onTokenSelectionWithSelectedMorphs",
data: overlay.selectedToken?.toJson() ?? {},
);
debugger(when: kDebugMode);
return;
}
selectedTag = choice;
MatrixState.pangeaController.putAnalytics.setState(
AnalyticsStream(
eventId: overlay.pangeaMessageEvent!.eventId,
roomId: overlay.pangeaMessageEvent!.room.id,
constructs: [
OneConstructUse(
useType: isCorrect(choice)
? ConstructUseTypeEnum.corM
: ConstructUseTypeEnum.incM,
lemma: choice,
constructType: ConstructTypeEnum.vocab,
metadata: ConstructUseMetaData(
roomId: overlay.pangeaMessageEvent!.room.id,
timeStamp: DateTime.now(),
eventId: overlay.pangeaMessageEvent!.eventId,
),
category: morph!.name,
form: token!.text.content,
),
],
origin: AnalyticsUpdateOrigin.wordZoom,
),
);
setState(() => {});
await Future.delayed(
const Duration(milliseconds: choiceArrayAnimationDuration),
);
// this removes just one of the options
// important because sometimes meanings are the same for different words
if (isCorrect(choice)) {
overlay.messageAnalyticsEntry
?.onActivityComplete(ActivityTypeEnum.morphId, token);
}
// kind of an odd way to do this, but should work
overlay.setState(() {});
// setState(() {});
}
@override
Widget build(BuildContext context) {
if (choices != null) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
spacing: 16.0,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 16.0,
children: [
MorphIcon(
morphFeature: morph!,
morphTag: null,
size: const Size(30, 30),
),
Text(
L10n.of(context).whatIsTheMorphTag(
morph!.getDisplayCopy(context),
token!.text.content,
),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
],
),
Wrap(
alignment: WrapAlignment.center,
runAlignment: WrapAlignment.center,
spacing: 8.0, // Adjust spacing between items
runSpacing: 8.0, // Adjust spacing between rows
children: choices!
.mapIndexed(
(index, choice) => MessageMorphChoiceItem(
cId: ConstructIdentifier(
lemma: choice,
type: ConstructTypeEnum.morph,
category: morph!.name,
),
onTap: () => onActivityChoice(choice),
isSelected: selectedTag == choice,
isGold: selectedTag != null ? isCorrect(choice) : null,
),
)
.toList(),
),
],
);
}
if (token != null && morph != null) {
return MorphFocusWidget(
token: token!,
morphFeature: morph!.name,
pangeaMessageEvent: widget.pangeaMessageEvent,
overlayController: overlay,
onEditDone: () => overlay.setState(() {}),
);
}
return const Center(
child: Text("Select a grammar icon for activities and details."),
);
}
}

View file

@ -0,0 +1,104 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:flutter/material.dart';
class MessageMorphChoiceItem extends StatefulWidget {
const MessageMorphChoiceItem({
super.key,
required this.onTap,
required this.isSelected,
required this.isGold,
required this.cId,
});
final ConstructIdentifier cId;
final void Function() onTap;
final bool isSelected;
final bool? isGold;
@override
MessageMorphChoiceItemState createState() => MessageMorphChoiceItemState();
}
class MessageMorphChoiceItemState extends State<MessageMorphChoiceItem> {
bool _isHovered = false;
@override
void didUpdateWidget(covariant MessageMorphChoiceItem oldWidget) {
if (oldWidget.isSelected != widget.isSelected ||
oldWidget.isGold != widget.isGold) {
setState(() {});
}
super.didUpdateWidget(oldWidget);
}
Future<void> onTap() async {
widget.onTap();
}
Color get _color {
if (widget.isSelected && widget.isGold != null) {
return widget.isGold!
? AppConfig.success.withAlpha((0.4 * 255).toInt())
: AppConfig.warning.withAlpha((0.4 * 255).toInt());
}
if (widget.isSelected) {
return AppConfig.primaryColor.withAlpha((0.4 * 255).toInt());
}
return _isHovered
? AppConfig.primaryColor.withAlpha((0.2 * 255).toInt())
: Colors.transparent;
}
@override
Widget build(BuildContext context) {
return InkWell(
onHover: (isHovered) => setState(() => _isHovered = isHovered),
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
onTap: onTap,
child: IntrinsicWidth(
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
color: _color,
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
),
padding: const EdgeInsets.symmetric(
vertical: 8.0,
horizontal: 12.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
spacing: 8.0,
children: [
SizedBox(
width: 40,
height: 40,
child: MorphIcon(
morphFeature: MorphFeaturesEnumExtension.fromString(
widget.cId.category,
),
morphTag: widget.cId.lemma,
size: const Size(40, 40),
),
),
Text(
getGrammarCopy(
category: widget.cId.category,
lemma: widget.cId.lemma,
context: context,
) ??
widget.cId.lemma,
style: Theme.of(context).textTheme.bodyLarge,
),
],
),
),
),
);
}
}

View file

@ -1,21 +1,21 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/word_emoji_choice.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_match_activity.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_mode_locked_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_translation_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/morphs/morphological_center_widget.dart';
import 'message_emoji_choice.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
class ReadingAssistanceInputBar extends StatelessWidget {
final ChatController controller;
@ -55,93 +55,42 @@ class ReadingAssistanceInputBar extends StatelessWidget {
}
Widget barContent(BuildContext context) {
if (token == null ||
!(overlayController.pangeaMessageEvent?.messageDisplayLangIsL2 ??
false)) {
return MessageEmojiChoice(
tokens: overlayController
.pangeaMessageEvent?.messageDisplayRepresentation?.tokens ??
[],
controller: controller,
overlayController: overlayController,
);
}
switch (overlayController.toolbarMode) {
// message meaning will not use the input bar (for now at least)
// maybe we move some choices there later
case MessageMode.messageMeaning:
case MessageMode.messageTranslation:
case MessageMode.messageTextToSpeech:
case MessageMode.messageSpeechToText:
case MessageMode.practiceActivity:
case MessageMode.wordZoom:
case MessageMode.noneSelected:
return MessageEmojiChoice(
tokens: overlayController
.pangeaMessageEvent?.messageDisplayRepresentation?.tokens ??
[],
controller: controller,
//TODO: show all emojis for the lemmas and allow sending normal reactions
return const SizedBox.shrink();
// return MessageEmojiChoice(
// controller: controller,
// overlayController: overlayController,
// );
case MessageMode.messageTranslation:
if (overlayController.isTranslationUnlocked) {
return MessageTranslationCard(
messageEvent: overlayController.pangeaMessageEvent!,
);
} else {
return MessageModeLockedCard(controller: overlayController);
}
case MessageMode.wordEmoji:
case MessageMode.messageMeaning:
case MessageMode.wordMeaning:
case MessageMode.listening:
return MessageMatchActivity(
overlayController: overlayController,
);
case MessageMode.wordEmoji:
return WordEmojiChoice(
form: overlayController.selectedToken!.text.content,
onEmojiChosen: () =>
overlayController.onActivityFinish(ActivityTypeEnum.emoji),
constructID: overlayController.selectedToken!.vocabConstructID,
);
case MessageMode.wordMeaning:
return getPracticeActivityCard(ActivityTypeEnum.wordMeaning);
case MessageMode.wordMorph:
if (overlayController.selectedMorphFeature != null) {
if (!token!.shouldDoActivity(
a: ActivityTypeEnum.morphId,
feature: overlayController.selectedMorphFeature,
tag: token!.getMorphTag(overlayController.selectedMorphFeature!),
)) {
return MorphFocusWidget(
token: token!,
morphFeature: overlayController.selectedMorphFeature!,
pangeaMessageEvent: overlayController.pangeaMessageEvent!,
overlayController: overlayController,
onEditDone: () => overlayController.initializeTokensAndMode(),
);
} else {
return getPracticeActivityCard(
ActivityTypeEnum.morphId,
overlayController.selectedMorphFeature,
);
}
} else {
/// we're not supposed to be here actually
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "selectedMorphFeature is null in wordMorph mode",
s: StackTrace.current,
data: token?.toJson() ?? {},
);
final String? nextFeature = overlayController
.selectedToken?.nextMorphFeatureEligibleForActivity;
if (nextFeature != null) {
return getPracticeActivityCard(
ActivityTypeEnum.morphId,
nextFeature,
);
} else {
// morph center widget with feature = "pos"
return MorphFocusWidget(
token: token!,
morphFeature: "pos",
pangeaMessageEvent: overlayController.pangeaMessageEvent!,
overlayController: overlayController,
onEditDone: () => overlayController.initializeTokensAndMode(),
);
}
}
return MessageMorphInputBarContent(
overlayController: overlayController,
pangeaMessageEvent: overlayController.pangeaMessageEvent!,
);
}
}
@ -158,9 +107,10 @@ class ReadingAssistanceInputBar extends StatelessWidget {
return const SizedBox.shrink();
}
return Flexible(
return Expanded(
child: Container(
height: AppConfig.readingAssistanceInputBarHeight,
padding: const EdgeInsets.all(8.0),
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: const BorderRadius.all(
@ -168,7 +118,9 @@ class ReadingAssistanceInputBar extends StatelessWidget {
),
),
alignment: Alignment.center,
child: barContent(context),
child: SingleChildScrollView(
child: barContent(context),
),
),
);
}

View file

@ -0,0 +1,43 @@
import 'package:flutter/material.dart';
class MeasureRenderBox extends StatefulWidget {
final Widget child;
final ValueChanged<RenderBox>? onChange;
const MeasureRenderBox({
super.key,
required this.child,
required this.onChange,
});
@override
MeasureRenderBoxState createState() => MeasureRenderBoxState();
}
class MeasureRenderBoxState extends State<MeasureRenderBox> {
Offset? _lastOffset;
Size? _lastSize;
void _updateOffset() {
if (widget.onChange == null) return;
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox != null) {
final offset = renderBox.localToGlobal(Offset.zero);
if (_lastOffset == null ||
_lastOffset != offset ||
_lastSize == null ||
_lastSize != renderBox.size) {
_lastOffset = offset;
_lastSize = renderBox.size;
widget.onChange!(renderBox);
setState(() {});
}
}
}
@override
Widget build(BuildContext context) {
WidgetsBinding.instance.addPostFrameCallback((_) => _updateOffset());
return widget.child;
}
}

View file

@ -1,5 +1,4 @@
import 'dart:developer';
import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -12,17 +11,13 @@ import 'package:fluffychat/pangea/choreographer/widgets/igc/card_error_widget.da
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart';
class MessageAudioCard extends StatefulWidget {
final PangeaMessageEvent messageEvent;
final MessageOverlayController overlayController;
final PangeaTokenText? selection;
final TtsController tts;
final Function(bool) setIsPlayingAudio;
final VoidCallback? onError;
@ -30,9 +25,7 @@ class MessageAudioCard extends StatefulWidget {
super.key,
required this.messageEvent,
required this.overlayController,
required this.tts,
required this.setIsPlayingAudio,
this.selection,
this.onError,
});
@ -44,92 +37,10 @@ class MessageAudioCardState extends State<MessageAudioCard> {
bool _isLoading = false;
PangeaAudioFile? audioFile;
int? sectionStartMS;
int? sectionEndMS;
@override
void initState() {
super.initState();
fetchAudio();
// initializeTTS();
}
// initializeTTS() async {
// tts.setupTTS().then((value) => setState(() {}));
// }
@override
void didUpdateWidget(covariant oldWidget) {
// if (oldWidget.selection != widget.selection && widget.selection != null) {
// debugPrint('selection changed');
// setSectionStartAndEndFromSelection();
// playSelectionAudio();
// }
super.didUpdateWidget(oldWidget);
}
void setSectionStartAndEnd(int? start, int? end) => mounted
? setState(() {
sectionStartMS = start;
sectionEndMS = end;
})
: null;
void setSectionStartAndEndFromSelection() async {
if (audioFile == null) {
// should never happen but just in case
debugger(when: kDebugMode);
return;
}
if (audioFile!.duration == null) {
// should never happen but just in case
debugger(when: kDebugMode);
ErrorHandler.logError(
e: 'audioFile duration is null in MessageAudioCardState',
data: {
'audioFile': audioFile,
},
);
return setSectionStartAndEnd(null, null);
}
// if there is no selection, we don't need to do anything
// but clear the section start and end
if (widget.selection == null) {
return setSectionStartAndEnd(null, null);
}
final PangeaTokenText selection = widget.selection!;
final List<TTSToken> tokens = audioFile!.tokens;
// find the token that corresponds to the selection
// set the start to the start of the token
// set the end to the start of the next token or to the duration of the audio if
// if there is no next token
for (int i = 0; i < tokens.length; i++) {
final TTSToken ttsToken = tokens[i];
if (ttsToken.text.offset == selection.offset) {
return setSectionStartAndEnd(
max(ttsToken.startMS - 150, 0),
min(ttsToken.endMS + 150, audioFile!.duration!),
);
}
}
// if we didn't find the token, we should pause if debug and log an error
debugger(when: kDebugMode);
ErrorHandler.logError(
e: 'could not find token for selection in MessageAudioCardState',
data: {
'selection': selection,
'tokens': tokens,
'sttTokens': audioFile!.tokens,
},
);
setSectionStartAndEnd(null, null);
}
Future<void> fetchAudio() async {
@ -151,7 +62,6 @@ class MessageAudioCardState extends State<MessageAudioCard> {
);
}
debugPrint("audio file is now: $audioFile. setting starts and ends...");
setSectionStartAndEndFromSelection();
if (mounted) setState(() => _isLoading = false);
} catch (e, s) {
widget.onError?.call();
@ -182,8 +92,8 @@ class MessageAudioCardState extends State<MessageAudioCard> {
? AudioPlayerWidget(
null,
matrixFile: audioFile,
sectionStartMS: sectionStartMS,
sectionEndMS: sectionEndMS,
sectionStartMS: null,
sectionEndMS: null,
color: Theme.of(context).colorScheme.onPrimaryContainer,
setIsPlayingAudio: widget.setIsPlayingAudio,
fontSize:

View file

@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:flutter/material.dart';
class MessageModeLockedCard extends StatelessWidget {
final MessageOverlayController controller;
@ -12,32 +10,24 @@ class MessageModeLockedCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: AppConfig.toolbarMinWidth,
maxHeight: AppConfig.toolbarMaxHeight,
),
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.lock_outline,
size: 40,
color: Theme.of(context).colorScheme.primary,
),
if (!InstructionsEnum.completeActivitiesToUnlock.isToggledOff) ...[
const SizedBox(height: 8),
const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.completeActivitiesToUnlock,
),
],
],
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Icon(
Icons.lock_outline,
size: 40,
color: Theme.of(context).colorScheme.primary,
),
),
if (!InstructionsEnum.completeActivitiesToUnlock.isToggledOff) ...[
const SizedBox(height: 8),
const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.completeActivitiesToUnlock,
bold: true,
),
],
],
);
}
}

View file

@ -1,27 +1,37 @@
import 'dart:async';
import 'dart:developer';
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/message_analytics_controller.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_form.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/event_wrappers/pangea_representation_event.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_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/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart';
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/match_feedback_model.dart';
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 {
@ -52,54 +62,51 @@ class MessageSelectionOverlay extends StatefulWidget {
class MessageOverlayController extends State<MessageSelectionOverlay>
with SingleTickerProviderStateMixin {
Event get event => widget._event;
/////////////////////////////////////
/// Variables
/////////////////////////////////////
MessageMode toolbarMode = MessageMode.noneSelected;
/// If doing a morphological activity, this is the selected morph feature.
String? selectedMorphFeature;
Map<ConstructIdentifier, LemmaInfoResponse>? messageLemmaInfos;
MorphFeaturesEnum? selectedMorph;
ConstructForm? selectedChoice;
PangeaTokenText? _selectedSpan;
List<PangeaTokenText>? _highlightedTokens;
bool initialized = false;
bool isPlayingAudio = false;
/// The text that the toolbar should target
/// If there is no selectedSpan, then the whole message is the target
/// If there is a selectedSpan, then the target is the selected text
String get targetText {
if (_selectedSpan == null || pangeaMessageEvent == null) {
return pangeaMessageEvent?.messageDisplayText ?? widget._event.body;
}
/// temporary stores of which matches have been made and
/// whether correct or not. allows user to quickly match
/// choices while we're still displaying feedback for previous
/// choices
final List<MatchFeedback> feedbackStates = [];
return pangeaMessageEvent!.messageDisplayText.substring(
_selectedSpan!.offset,
_selectedSpan!.offset + _selectedSpan!.length,
);
}
final GlobalKey<ReadingAssistanceContentState> wordZoomKey = GlobalKey();
PangeaToken? get selectedToken =>
pangeaMessageEvent?.messageDisplayRepresentation?.tokens
?.firstWhereOrNull(isTokenSelected);
/// Whether the overlay is currently displaying a selection
bool get isSelection => _selectedSpan != null || _highlightedTokens != null;
PangeaTokenText? get selectedSpan => _selectedSpan;
// either a MorphFeatureEnum or a PartOfSpeechEnum
// String? modeLevel;
/////////////////////////////////////
/// Lifecycle
/////////////////////////////////////
@override
void dispose() {
super.dispose();
}
@override
void initState() {
super.initState();
initializeTokensAndMode();
super.initState();
}
Future<void> initializeTokensAndMode() async {
try {
debugPrint("what");
RepresentationEvent? repEvent =
pangeaMessageEvent?.messageDisplayRepresentation;
repEvent ??= await _fetchNewRepEvent();
@ -130,7 +137,24 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
choreo: pangeaMessageEvent!.originalSent?.choreo,
);
}
// Get all the lemma infos
final messageVocabConstructIds = pangeaMessageEvent!
.messageDisplayRepresentation!.tokensToSave
.map((e) => e.vocabConstructID)
.toList();
final List<Future<LemmaInfoResponse>> lemmaInfoFutures =
messageVocabConstructIds
.map((token) => token.getLemmaInfo())
.toList();
final List<LemmaInfoResponse> lemmaInfos =
await Future.wait(lemmaInfoFutures);
messageLemmaInfos = Map.fromIterables(
messageVocabConstructIds,
lemmaInfos,
);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
@ -148,21 +172,20 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
Future<void> _setInitialToolbarMode() async {
if (pangeaMessageEvent?.isAudioMessage ?? false) {
updateToolbarMode(MessageMode.messageSpeechToText);
updateToolbarMode(MessageMode.listening);
return;
}
// 1) if we have a hidden word activity, then we should start with that
if (messageAnalyticsEntry?.nextActivity?.activityType ==
ActivityTypeEnum.hiddenWordListening) {
if (messageAnalyticsEntry?.hasHiddenWordActivity ?? false) {
updateToolbarMode(MessageMode.practiceActivity);
return;
}
if (selectedToken != null) {
updateToolbarMode(selectedToken!.modeForToken);
return;
}
// if (selectedToken != null) {
// updateToolbarMode(selectedToken!.modeForToken);
// return;
// }
// Note: this setting is now hidden so this will always be false
// leaving this here in case we want to bring it back
@ -183,18 +206,17 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
}
// should not already be involved in a hidden word activity
final isInHiddenWordActivity =
messageAnalyticsEntry!.isTokenInHiddenWordActivity(
widget._initialSelectedToken!,
);
// final isInHiddenWordActivity =
// messageAnalyticsEntry!.isTokenInHiddenWordActivity(
// widget._initialSelectedToken!,
// );
// whether the activity should generally be involved in an activity
final selected =
!isInHiddenWordActivity ? widget._initialSelectedToken : null;
if (selected != null) {
_updateSelectedSpan(selected.text);
// // whether the activity should generally be involved in an activity
if (messageAnalyticsEntry?.hasHiddenWordActivity == true) {
return;
}
_updateSelectedSpan(widget._initialSelectedToken!.text);
}
/////////////////////////////////////
@ -206,12 +228,21 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
/// This is a workaround to prevent that error
@override
void setState(VoidCallback fn) {
// if (pangeaMessageEvent != null) {
// debugger(when: kDebugMode);
// modeLevel = toolbarMode.currentChoiceMode(this, pangeaMessageEvent!);
// } else {
// debugger(when: kDebugMode);
// }
final phase = SchedulerBinding.instance.schedulerPhase;
if (mounted &&
(phase == SchedulerPhase.idle ||
phase == SchedulerPhase.postFrameCallbacks)) {
// It's safe to call setState immediately
try {
wordZoomKey.currentState?.setState(() {});
super.setState(fn);
} catch (e, s) {
ErrorHandler.logError(
@ -236,114 +267,121 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
}
}
/// Update to [selectedSpan]
/// [forceMode] is used to force a specific mode
void _updateSelectedSpan(
PangeaTokenText selectedSpan, [
MessageMode? forceMode,
]) {
if (forceMode == null && selectedSpan == _selectedSpan) {
_selectedSpan = null;
updateToolbarMode(MessageMode.noneSelected);
setState(() {});
return;
}
_selectedSpan = selectedSpan;
if (!(messageAnalyticsEntry?.hasHiddenWordActivity ?? false)) {
widget.chatController.choreographer.tts.tryToSpeak(
selectedSpan.content,
context,
targetID: null,
/// Update [selectedSpan]
void _updateSelectedSpan(PangeaTokenText selectedSpan, [bool force = false]) {
// close overlay of previous token
if (selectedToken != null) {
MatrixState.pAnyState.disposeByWidgetKey(
selectedToken!.text.uniqueKey,
);
}
final nextModeForToken = forceMode ?? selectedToken!.modeForToken;
if (toolbarMode != nextModeForToken) {
debugPrint("_updateSelectedSpan: setting toolbarMode to wordZoom");
updateToolbarMode(nextModeForToken);
if (selectedSpan == _selectedSpan && !force) {
_selectedSpan = null;
} else {
_selectedSpan = selectedSpan;
}
if (selectedToken != null) {
final entry = ReadingAssistanceContent(
key: wordZoomKey,
pangeaMessageEvent: pangeaMessageEvent!,
overlayController: this,
);
OverlayUtil.showPositionedCard(
context: context,
cardToShow: entry,
transformTargetId: selectedToken!.text.uniqueKey,
closePrevOverlay: false,
backDropToDismiss: false,
addBorder: false,
overlayKey: selectedToken!.text.uniqueKey,
maxHeight: AppConfig.toolbarMaxHeight,
maxWidth: AppConfig.toolbarMinWidth,
);
}
setState(() {});
}
void updateToolbarMode(MessageMode mode, [String? feature]) => setState(() {
debugger(
when: kDebugMode &&
selectedToken == null &&
[
MessageMode.wordMorph,
MessageMode.wordZoom,
MessageMode.wordEmoji,
MessageMode.wordZoom,
].contains(mode),
);
if (toolbarMode == mode) {
if (selectedToken == null) {
toolbarMode = MessageMode.noneSelected;
selectedMorphFeature = null;
} else if (mode != MessageMode.wordMorph) {
debugPrint('toolbarMode == mode: setting toolbarMode to wordZoom');
toolbarMode = MessageMode.wordZoom;
} else {
if (selectedMorphFeature != feature) {
selectedMorphFeature = feature;
} else {
selectedMorphFeature = null;
toolbarMode = MessageMode.wordZoom;
}
}
} else {
switch (mode) {
case MessageMode.noneSelected:
selectedMorphFeature = null;
break;
case MessageMode.wordMorph:
selectedMorphFeature =
feature ?? selectedToken?.nextMorphFeatureEligibleForActivity;
if (selectedMorphFeature == null) {
updateToolbarMode(MessageMode.wordZoom);
return;
}
break;
case MessageMode.wordZoom:
selectedMorphFeature = null;
break;
case MessageMode.wordEmoji:
selectedMorphFeature = null;
break;
case MessageMode.wordMeaning:
selectedMorphFeature = null;
break;
case MessageMode.practiceActivity:
break;
case MessageMode.messageTextToSpeech:
if (isPlayingAudio) {
//TODO stop audio
} else {
// Play audio
}
break;
case MessageMode.messageSpeechToText:
_selectedSpan = null;
break;
case MessageMode.messageTranslation:
_selectedSpan = null;
break;
case MessageMode.messageMeaning:
_selectedSpan = null;
break;
}
toolbarMode = mode;
if (mode != MessageMode.messageTextToSpeech) {
_highlightedTokens = null;
}
}
void updateToolbarMode(MessageMode mode) => setState(() {
selectedChoice = null;
toolbarMode = mode;
});
void onChoiceSelect(ConstructForm choice, [bool force = false]) {
if (selectedChoice == choice && !force) {
selectedChoice = null;
} else {
selectedChoice = choice;
}
setState(() {});
}
void onMorphActivitySelect(PangeaToken token, MorphFeaturesEnum morph) {
if (toolbarMode != MessageMode.wordMorph) {
updateToolbarMode(MessageMode.wordMorph);
}
selectedMorph = morph;
_updateSelectedSpan(token.text, true);
}
// only used for word meaning, emoji, and word focus listening atm
Future<void> onMatchAttempt(
PangeaToken token,
ConstructForm choice,
) async {
final ActivityTypeEnum activityType = toolbarMode.associatedActivityType!;
final bool isCorrect = token.vocabConstructID == choice.cId;
final ConstructUseTypeEnum? useType =
isCorrect ? activityType.correctUse : activityType.incorrectUse;
if (useType != null) {
MatrixState.pangeaController.putAnalytics.setState(
AnalyticsStream(
eventId: pangeaMessageEvent!.eventId,
roomId: pangeaMessageEvent!.room.id,
constructs: [
OneConstructUse(
useType: useType,
lemma: token.vocabConstructID.lemma,
constructType: ConstructTypeEnum.vocab,
metadata: ConstructUseMetaData(
roomId: pangeaMessageEvent!.room.id,
timeStamp: DateTime.now(),
eventId: pangeaMessageEvent!.eventId,
),
category: token.vocabConstructID.category,
form: token.text.content,
),
],
origin: AnalyticsUpdateOrigin.wordZoom,
),
);
}
feedbackStates.add(MatchFeedback(form: choice, isCorrect: isCorrect));
selectedChoice = null;
setState(() => {});
await Future.delayed(
const Duration(milliseconds: 2000),
);
if (isCorrect) {
messageAnalyticsEntry?.onActivityComplete(activityType, token);
}
feedbackStates.removeWhere((e) => e.form == choice);
setState(() => {});
}
/////////////////////////////////////
/// Getters
////////////////////////////////////
@ -363,9 +401,43 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
(pangeaMessageEvent?.proportionOfActivitiesCompleted ?? 1) >= 1 ||
!messageInUserL2;
// bool get isEmojiDone =>
// pangeaMessageEvent?.messageDisplayRepresentation?.tokensToSave
// .every((token) => token.vocabConstructID.userSetEmoji.isNotEmpty) ??
// false;
bool get isEmojiDone =>
messageAnalyticsEntry?.activities(ActivityTypeEnum.emoji).isEmpty == true;
bool get isMeaningDone =>
messageAnalyticsEntry?.activities(ActivityTypeEnum.wordMeaning).isEmpty ==
true;
bool get isListeningDone =>
messageAnalyticsEntry
?.activities(ActivityTypeEnum.wordFocusListening)
.isEmpty ==
true;
bool get isMorphDone =>
messageAnalyticsEntry?.activities(ActivityTypeEnum.morphId).isEmpty ==
true;
/// you have to complete one of the mode mini-games to unlock translation
bool get isTranslationUnlocked =>
!messageInUserL2 ||
(messageLemmaInfos?.isEmpty ?? false) ||
isEmojiDone ||
isMeaningDone ||
isListeningDone ||
isMorphDone;
bool get isTotallyDone =>
isEmojiDone && isMeaningDone && isListeningDone && isMorphDone;
MessageAnalyticsEntry? get messageAnalyticsEntry =>
pangeaMessageEvent?.messageDisplayRepresentation?.tokens != null
? MatrixState.pangeaController.getAnalytics.perMessage.get(
? MessageAnalyticsController.get(
pangeaMessageEvent!.messageDisplayRepresentation!.tokens!,
pangeaMessageEvent!,
)
@ -375,6 +447,15 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
pangeaMessageEvent?.messageDisplayLangCode ==
MatrixState.pangeaController.languageController.userL2?.langCode;
PangeaToken? get selectedToken =>
pangeaMessageEvent?.messageDisplayRepresentation?.tokens
?.firstWhereOrNull(isTokenSelected);
/// Whether the overlay is currently displaying a selection
bool get isSelection => _selectedSpan != null || _highlightedTokens != null;
PangeaTokenText? get selectedSpan => _selectedSpan;
///////////////////////////////////
/// User action handlers
/////////////////////////////////////
@ -394,27 +475,27 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
}
}
void onNextActivityRequest() {
if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: "Tokens are null in onNextActivityRequest",
data: {},
);
return;
}
// // void onNextActivityRequest() {
// // if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens == null) {
// // debugger(when: kDebugMode);
// // ErrorHandler.logError(
// // e: "Tokens are null in onNextActivityRequest",
// // data: {},
// // );
// // return;
// // }
for (final token in pangeaMessageEvent!
.messageDisplayRepresentation!.tokens!
.where((t) => t.lemma.saveVocab)) {
final MessageMode nextActivityMode = token.modeForToken;
if (nextActivityMode != MessageMode.wordZoom) {
_selectedSpan = token.text;
_updateSelectedSpan(token.text, nextActivityMode);
return;
}
}
}
// // for (final token in pangeaMessageEvent!
// // .messageDisplayRepresentation!.tokens!
// // .where((t) => t.lemma.saveVocab)) {
// // final MessageMode nextActivityMode = token.modeForToken;
// // if (nextActivityMode != MessageMode.wordZoom) {
// // _selectedSpan = token.text;
// // _updateSelectedSpan(token.text);
// // return;
// // }
// // }
// // }
///////////////////////////////////
/// Functions
@ -460,14 +541,14 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
/// When an activity is completed, we need to update the state
/// and check if the toolbar should be unlocked
void onActivityFinish(ActivityTypeEnum activityType) {
messageAnalyticsEntry!.onActivityComplete();
void onActivityFinish(ActivityTypeEnum activityType, PangeaToken? token) {
messageAnalyticsEntry!.onActivityComplete(activityType, null);
if (selectedToken == null) {
updateToolbarMode(MessageMode.noneSelected);
}
updateToolbarMode(selectedToken!.modeForToken);
// updateToolbarMode(selectedToken!.modeForToken);
if (!mounted) return;
setState(() {});
@ -483,14 +564,44 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void onClickOverlayMessageToken(
PangeaToken token,
) {
if (toolbarMode == MessageMode.practiceActivity &&
messageAnalyticsEntry?.nextActivity?.activityType ==
ActivityTypeEnum.hiddenWordListening) {
if (messageAnalyticsEntry?.hasHiddenWordActivity == true) {
return;
}
/// we don't want to associate the audio with the text in this mode
if (messageAnalyticsEntry?.hasActivity(
ActivityTypeEnum.wordFocusListening,
token,
) ==
false) {
widget.chatController.choreographer.tts.tryToSpeak(
token.text.content,
context,
targetID: null,
);
}
_updateSelectedSpan(token.text);
setState(() {});
}
void onDragTargetClick(PangeaToken token) {
if (toolbarMode.associatedActivityType == null) {
debugger(when: kDebugMode);
return;
}
// supporting highlighting of the target then a click on an option is possible
// however, a user might want to click an option to hear the audio then
// accidentally submit. for this reason, holding off on supporting that flow
// to see if it feels needed
if (selectedChoice == null) {
return;
}
onMatchAttempt(
token,
selectedChoice!,
);
}
/// Whether the given token is currently selected or highlighted

View file

@ -13,11 +13,13 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/overlay_footer.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/overlay_center_content.dart';
import 'package:fluffychat/pangea/toolbar/widgets/overlay_header.dart';
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button_and_progress_row.dart';
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button_column.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -51,20 +53,34 @@ class MessageSelectionPositioner extends StatefulWidget {
class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
with TickerProviderStateMixin {
late AnimationController _animationController;
Animation<double>? _overlayPositionAnimation;
Animation<Offset>? _overlayOffsetAnimation;
Animation<Offset>? _buttonsOffsetAnimation;
Animation<Size>? _messageSizeAnimation;
StreamSubscription? _reactionSubscription;
/// if the message height is too tall to fit with the tools, adjust the message height
double? _adjustedMessageHeight;
Offset? _centeredMessageOffset;
Size? _centeredMessageSize;
final Completer _centeredMessageCompleter = Completer();
Offset? _centeredButtonsOffset;
Size? _centeredButtonsSize;
final Completer _centeredButtonsCompleter = Completer();
bool _finishedAnimation = false;
@override
void initState() {
super.initState();
_animationController = AnimationController(
vsync: this,
duration:
const Duration(milliseconds: AppConfig.overlayAnimationDuration),
duration: const Duration(
milliseconds: AppConfig.overlayAnimationDuration,
// seconds: 5,
),
);
_reactionSubscription =
@ -86,27 +102,72 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
);
},
).listen((_) => setState(() {}));
Future.wait([
_centeredMessageCompleter.future,
_centeredButtonsCompleter.future,
]).then((_) => _startAnimation());
}
@override
void didUpdateWidget(MessageSelectionPositioner oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.overlayController.toolbarMode !=
widget.overlayController.toolbarMode) {
setState(() {});
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
}
@override
void dispose() {
_animationController.dispose();
_reactionSubscription?.cancel();
super.dispose();
}
void _setCenteredMessageSize(RenderBox renderBox) {
if (_finishedAnimation) return;
_centeredMessageSize = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
_centeredMessageOffset = Offset(
offset.dx - _columnWidth - _horizontalPadding - 2.0,
_mediaQuery!.size.height -
offset.dy -
renderBox.size.height -
_reactionsHeight,
);
setState(() {});
if (!_centeredMessageCompleter.isCompleted) {
_centeredMessageCompleter.complete();
}
}
void _setCenteredButtonsSize(RenderBox renderBox) {
if (_finishedAnimation) return;
_centeredButtonsSize = renderBox.size;
final offset = renderBox.localToGlobal(Offset.zero);
_centeredButtonsOffset = Offset(
offset.dx - _columnWidth - _horizontalPadding - 2.0,
offset.dy,
);
setState(() {});
if (!_centeredButtonsCompleter.isCompleted) {
_centeredButtonsCompleter.complete();
}
}
void _startAnimation() {
if (_mediaQuery == null) {
return;
}
final bool hasHeaderOverflow =
_remainingTopSpace < AppConfig.toolbarSpacing;
final bool hasFooterOverflow =
_remainingBottomSpace < AppConfig.toolbarSpacing;
if (!hasHeaderOverflow && !hasFooterOverflow || _mediaQuery == null) {
return;
}
double adjustedBottomOffset = _totalToolbarBottomOffset;
double scrollOffset = 0;
// if the message height is too tall to fit, adjust the message height
if (_totalVerticalSpace! < _maxTotalToolbarHeight) {
_adjustedMessageHeight = _totalVerticalSpace! -
@ -117,22 +178,13 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
_adjustedMessageHeight = max(_adjustedMessageHeight!, 0);
}
// if the overlay could have header overflow if the message wasn't shifted, we want to shift
// it down so the bottom to give it enough space.
if (hasHeaderOverflow) {
// what is the distance between the current top offset of the toolbar and the desired top offset?
final double neededShift =
(_headerHeight - _totalToolbarTopOffset) + AppConfig.toolbarSpacing;
adjustedBottomOffset = _totalToolbarBottomOffset - neededShift;
} else if (hasFooterOverflow) {
adjustedBottomOffset = _footerHeight + AppConfig.toolbarSpacing;
}
scrollOffset = adjustedBottomOffset - _totalToolbarBottomOffset;
_overlayPositionAnimation = Tween<double>(
begin: _totalToolbarBottomOffset,
end: adjustedBottomOffset,
_overlayOffsetAnimation = Tween<Offset>(
begin: Offset(
_ownMessage ? _messageRightOffset : _messageLeftOffset,
_totalToolbarBottomOffset,
),
// For own messages, dx is the right offset. For other's messages, dx is the left offset.
end: _centeredMessageOffset,
).animate(
CurvedAnimation(
parent: _animationController,
@ -140,20 +192,48 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
),
);
widget.chatController.scrollController.animateTo(
widget.chatController.scrollController.offset - scrollOffset,
duration:
const Duration(milliseconds: AppConfig.overlayAnimationDuration),
curve: FluffyThemes.animationCurve,
_buttonsOffsetAnimation = Tween<Offset>(
begin: Offset(
_ownMessage
? (_centeredButtonsSize!.width * -1)
: _centeredButtonsSize!.width + _mediaQuery!.size.width,
_totalToolbarBottomOffset,
),
end: _centeredButtonsOffset,
).animate(
CurvedAnimation(
parent: _animationController,
curve: FluffyThemes.animationCurve,
),
);
_animationController.forward();
}
@override
void dispose() {
_animationController.dispose();
_reactionSubscription?.cancel();
super.dispose();
_messageSizeAnimation = Tween<Size>(
begin: Size(
_messageSize.width,
_messageHeight,
),
end: _centeredMessageSize,
).animate(
CurvedAnimation(
parent: _animationController,
curve: FluffyThemes.animationCurve,
),
);
// _contentSizeAnimation = Tween<double>(
// begin: 0,
// end: 1,
// ).animate(
// CurvedAnimation(
// parent: _animationController,
// curve: FluffyThemes.animationCurve,
// ),
// );
_animationController.forward().then((_) {
_finishedAnimation = true;
if (mounted) setState(() {});
});
}
T _runWithLogging<T>(
@ -175,7 +255,7 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
}
}
bool get showDetails =>
bool get _showDetails =>
(Matrix.of(context).store.getBool(SettingKeys.displayChatDetailsColumn) ??
false) &&
FluffyThemes.isThreeColumnMode(context) &&
@ -221,7 +301,7 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
//TODO: figure out where the 16 and 8 come from and use references instead of hard-coded values
static const _messageDefaultLeftMargin = Avatar.defaultSize + 16 + 8;
double get _messageMaxWidth {
double get _toolbarMaxWidth {
final double messageMargin =
widget.event.isActivityMessage ? 0 : Avatar.defaultSize + 16 + 8;
final bool showingDetails = widget.chatController.displayChatDetailsColumn;
@ -257,21 +337,16 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
);
}
double get _messageTopOffset {
return _messageOffset.dy -
(_mediaQuery?.padding.top ?? 0) +
(_mediaQuery?.viewPadding.top ?? 0);
}
double get _messageBottomOffset =>
_mediaQuery!.size.height - _messageOffset.dy - _messageHeight;
double get _messageLeftOffset =>
_messageOffset.dx - _columnWidth - _horizontalPadding;
double get _messageLeftOffset => max(
_messageOffset.dx - _columnWidth - _horizontalPadding,
0,
);
double get _messageRightOffset {
if (_mediaQuery == null ||
widget.event.senderId != widget.event.room.client.userID) {
if (_mediaQuery == null || !_ownMessage) {
return 0;
}
return _mediaQuery!.size.width -
@ -327,28 +402,13 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
AppConfig.toolbarSpacing +
AppConfig.toolbarMaxHeight;
double get _maxTotalToolbarHeight {
return max(AppConfig.toolbarButtonsColumnHeight, _maxTotalCenterHeight);
}
double get _totalToolbarTopOffset {
final topOffset = _messageTopOffset -
(AppConfig.toolbarSpacing + AppConfig.toolbarMaxHeight);
final addedColumnOffset =
max(AppConfig.toolbarButtonsColumnHeight - _maxTotalCenterHeight, 0);
return topOffset - addedColumnOffset;
}
double get _maxTotalToolbarHeight => _maxTotalCenterHeight;
double get _totalToolbarBottomOffset =>
_messageBottomOffset - _reactionsHeight;
/// The remaining space between the top of the screen and the top of the toolbar.
/// Negative if the toolbar is overflowing the top of the screen.
double get _remainingTopSpace => _totalToolbarTopOffset - _headerHeight;
/// The remaining space between the bottom of the screen and the bottom of the toolbar.
/// Negative if the toolbar is overflowing the bottom of the screen.
double get _remainingBottomSpace => _totalToolbarBottomOffset - _footerHeight;
bool get _ownMessage =>
widget.event.senderId == widget.event.room.client.userID;
@override
Widget build(BuildContext context) {
@ -362,58 +422,75 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
right: _horizontalPadding,
),
child: Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _overlayPositionAnimation ?? _animationController,
builder: (context, child) {
return Positioned(
// subtract width of
left: widget.event.senderId != widget.event.room.client.userID
? max(
_messageLeftOffset -
AppConfig.toolbarButtonsColumnWidth,
0,
)
: null,
right: widget.event.senderId == widget.event.room.client.userID
? _messageRightOffset
: _messageRightOffset,
bottom: _overlayPositionAnimation?.value ??
_totalToolbarBottomOffset,
child: Row(
mainAxisAlignment:
widget.event.senderId == widget.event.room.client.userID
? MainAxisAlignment.end
: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
// fixed height and width container
ToolbarButtonAndProgressColumn(
event: widget.event,
overlayController: widget.overlayController,
shouldShowToolbarButtons: showToolbarButtons,
width: AppConfig.toolbarButtonsColumnWidth,
height: AppConfig.toolbarButtonsColumnHeight,
),
OverlayCenterContent(
messageHeight: _messageHeight,
messageWidth: _messageSize.width,
maxWidth: _messageMaxWidth,
event: widget.event,
pangeaMessageEvent: widget.pangeaMessageEvent,
nextEvent: widget.nextEvent,
prevEvent: widget.prevEvent,
overlayController: widget.overlayController,
chatController: widget.chatController,
hasReactions: _hasReactions,
shouldShowToolbarButtons: showToolbarButtons,
),
],
),
);
},
Opacity(
opacity: _finishedAnimation ? 1.0 : 0.0,
child: OverlayCenterContent(
event: widget.event,
messageHeight: _messageHeight,
messageWidth: _messageSize.width,
toolbarMaxWidth: _toolbarMaxWidth,
overlayController: widget.overlayController,
chatController: widget.chatController,
pangeaMessageEvent: widget.pangeaMessageEvent,
nextEvent: widget.nextEvent,
prevEvent: widget.prevEvent,
showToolbarButtons: showToolbarButtons,
hasReactions: _hasReactions,
onChangeMessageSize: _setCenteredMessageSize,
onChangeButtonsSize: _setCenteredButtonsSize,
isTransitionAnimation: false,
),
),
if (!_finishedAnimation)
AnimatedBuilder(
animation: _overlayOffsetAnimation ?? _animationController,
builder: (context, child) {
return Positioned(
left: _ownMessage
? null
: (_overlayOffsetAnimation?.value)?.dx ??
_messageLeftOffset,
right: _ownMessage
? (_overlayOffsetAnimation?.value)?.dx ??
_messageRightOffset
: null,
bottom: (_overlayOffsetAnimation?.value)?.dy ??
_totalToolbarBottomOffset,
child: OverlayCenterContent(
event: widget.event,
messageHeight: _messageHeight,
messageWidth: _messageSize.width,
toolbarMaxWidth: _toolbarMaxWidth,
overlayController: widget.overlayController,
chatController: widget.chatController,
pangeaMessageEvent: widget.pangeaMessageEvent,
nextEvent: widget.nextEvent,
prevEvent: widget.prevEvent,
showToolbarButtons: showToolbarButtons,
hasReactions: _hasReactions,
sizeAnimation: _messageSizeAnimation,
isTransitionAnimation: true,
),
);
},
),
if (!_finishedAnimation && _buttonsOffsetAnimation != null)
AnimatedBuilder(
animation: _buttonsOffsetAnimation!,
builder: (context, child) {
return Positioned(
left: (_buttonsOffsetAnimation?.value)!.dx,
top: _centeredButtonsOffset?.dy,
child: ToolbarButtonRow(
event: widget.event,
overlayController: widget.overlayController,
shouldShowToolbarButtons: showToolbarButtons,
),
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: Row(
@ -423,6 +500,11 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// ToolbarButtonRow(
// event: widget.overlayController.pangeaMessageEvent!.event,
// overlayController: widget.overlayController,
// shouldShowToolbarButtons: showToolbarButtons,
// ),
OverlayFooter(
controller: widget.chatController,
overlayController: widget.overlayController,
@ -431,7 +513,7 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
],
),
),
if (showDetails)
if (_showDetails)
const SizedBox(
width: FluffyThemes.columnWidth,
),
@ -444,6 +526,18 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
children: [
SizedBox(height: _mediaQuery?.padding.top ?? 0),
OverlayHeader(controller: widget.chatController),
widget.overlayController.toolbarMode.instructionsEnum != null
? Container(
constraints: const BoxConstraints(
maxWidth: 400,
),
child: InstructionsInlineTooltip(
instructionsEnum: widget
.overlayController.toolbarMode.instructionsEnum!,
bold: true,
),
)
: const SizedBox.shrink(),
],
),
),

View file

@ -1,25 +1,32 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart';
import 'package:fluffychat/pangea/common/utils/any_state_holder.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/utils/message_text_util.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/message_token_text/message_token_button.dart';
import 'package:fluffychat/pangea/practice_activities/message_analytics_controller.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/utils/url_launcher.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_linkify/flutter_linkify.dart';
/// Question - does this need to be stateful or does this work?
/// Need to test.
///
class MessageTokenText extends StatelessWidget {
final PangeaMessageEvent _pangeaMessageEvent;
final TextStyle _style;
final bool Function(PangeaToken)? _isSelected;
final void Function(PangeaToken)? _onClick;
final bool Function(PangeaToken)? _isHighlighted;
final MessageMode? _messageMode;
final MessageOverlayController? _overlayController;
final bool _isTransitionAnimation;
const MessageTokenText({
super.key,
@ -28,16 +35,24 @@ class MessageTokenText extends StatelessWidget {
required TextStyle style,
required void Function(PangeaToken)? onClick,
bool Function(PangeaToken)? isSelected,
bool Function(PangeaToken)? isHighlighted,
MessageMode? messageMode,
MessageOverlayController? overlayController,
bool isTransitionAnimation = false,
}) : _onClick = onClick,
_isSelected = isSelected,
_style = style,
_pangeaMessageEvent = pangeaMessageEvent;
_pangeaMessageEvent = pangeaMessageEvent,
_messageMode = messageMode,
_isHighlighted = isHighlighted,
_overlayController = overlayController,
_isTransitionAnimation = isTransitionAnimation;
List<PangeaToken>? get _tokens =>
_pangeaMessageEvent.messageDisplayRepresentation?.tokens;
MessageAnalyticsEntry? get messageAnalyticsEntry => _tokens != null
? MatrixState.pangeaController.getAnalytics.perMessage.get(
? MessageAnalyticsController.get(
_tokens!,
_pangeaMessageEvent,
)
@ -60,42 +75,18 @@ class MessageTokenText extends StatelessWidget {
return MessageTextWidget(
pangeaMessageEvent: _pangeaMessageEvent,
style: _style,
existingStyle: _style,
messageAnalyticsEntry: messageAnalyticsEntry,
isSelected: _isSelected,
onClick: callOnClick,
messageMode: _messageMode,
isHighlighted: _isHighlighted,
overlayController: _overlayController,
isTransitionAnimation: _isTransitionAnimation,
);
}
}
class TokenPosition {
/// Start index of the full substring in the message
final int start;
/// End index of the full substring in the message
final int end;
/// Start index of the token in the message
final int tokenStart;
/// End index of the token in the message
final int tokenEnd;
final bool selected;
final bool hideContent;
final PangeaToken? token;
const TokenPosition({
required this.start,
required this.end,
required this.tokenStart,
required this.tokenEnd,
required this.hideContent,
required this.selected,
this.token,
});
}
class HiddenText extends StatelessWidget {
final String text;
final TextStyle style;
@ -143,27 +134,72 @@ class HiddenText extends StatelessWidget {
class MessageTextWidget extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final TextStyle style;
final TextStyle existingStyle;
final MessageAnalyticsEntry? messageAnalyticsEntry;
final bool Function(PangeaToken)? isSelected;
final void Function(TokenPosition tokenPosition)? onClick;
final bool Function(PangeaToken)? isHighlighted;
final bool? softWrap;
final int? maxLines;
final TextOverflow? overflow;
final MessageMode? messageMode;
final Animation<double>? contentSizeAnimation;
final MessageOverlayController? overlayController;
final bool isTransitionAnimation;
const MessageTextWidget({
super.key,
required this.pangeaMessageEvent,
required this.style,
required this.existingStyle,
this.messageAnalyticsEntry,
this.isSelected,
this.onClick,
this.softWrap,
this.maxLines,
this.overflow,
this.messageMode,
this.isHighlighted,
this.contentSizeAnimation,
this.overlayController,
this.isTransitionAnimation = false,
});
TextStyle get style => overlayController != null
? existingStyle.copyWith(
fontSize: 22,
)
: existingStyle;
/// for some reason, this isn't the same as tokenTextWidth
double tokenTextWidthForContainer(PangeaToken token) {
final textPainter = TextPainter(
text: TextSpan(text: token.text.content, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
)..layout();
return textPainter.width;
}
Color backgroundColor(TokenPosition tokenPosition) {
final hideTokenHighlights = messageAnalyticsEntry != null &&
(messageAnalyticsEntry!.hasHiddenWordActivity ||
messageAnalyticsEntry!.hasMessageMeaningActivity);
Color backgroundColor = Colors.transparent;
if (!hideTokenHighlights) {
if (tokenPosition.selected) {
backgroundColor = AppConfig.primaryColor;
}
// else if (tokenPosition.isHighlighted) {
// backgroundColor = AppConfig.success.withAlpha(80);
// }
}
return backgroundColor;
}
@override
Widget build(BuildContext context) {
final Characters messageCharacters =
@ -173,6 +209,7 @@ class MessageTextWidget extends StatelessWidget {
pangeaMessageEvent,
messageAnalyticsEntry: messageAnalyticsEntry,
isSelected: isSelected,
isHighlighted: isHighlighted,
);
if (tokenPositions == null) {
@ -185,10 +222,6 @@ class MessageTextWidget extends StatelessWidget {
);
}
final hideTokenHighlights = messageAnalyticsEntry != null &&
(messageAnalyticsEntry!.hasHiddenWordActivity ||
messageAnalyticsEntry!.hasMessageMeaningActivity);
final theme = Theme.of(context);
final ownMessage =
pangeaMessageEvent.senderId == Matrix.of(context).client.userID;
@ -205,24 +238,13 @@ class MessageTextWidget extends StatelessWidget {
text: TextSpan(
children:
tokenPositions.mapIndexed((int i, TokenPosition tokenPosition) {
final shouldDo = pangeaMessageEvent.shouldDoActivity(
token: tokenPosition.token,
a: ActivityTypeEnum.wordMeaning,
feature: null,
tag: null,
);
final didMeaningActivity =
tokenPosition.token?.didActivitySuccessfully(
ActivityTypeEnum.wordMeaning,
) ??
true;
final substring = messageCharacters
.skip(tokenPosition.start)
.take(tokenPosition.end - tokenPosition.start)
.toString();
<<<<<<< HEAD
=======
Color backgroundColor = Colors.transparent;
if (!hideTokenHighlights) {
if (tokenPosition.selected) {
@ -235,6 +257,7 @@ class MessageTextWidget extends StatelessWidget {
}
}
>>>>>>> main
if (tokenPosition.token?.pos == 'SPACE') {
return const TextSpan(text: '\n');
}
@ -257,68 +280,121 @@ class MessageTextWidget extends StatelessWidget {
.take(endSplitIndex - startSplitIndex)
.toString();
final token = tokenPosition.token!;
final tokenWidth = tokenTextWidthForContainer(token);
return WidgetSpan(
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: onClick != null
? () => onClick?.call(tokenPosition)
: null,
child: RichText(
text: TextSpan(
children: [
if (start.isNotEmpty)
LinkifySpan(
text: start,
style: style,
linkStyle: TextStyle(
decoration: TextDecoration.underline,
color: linkColor,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
tokenPosition.hideContent
? WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GestureDetector(
onTap: onClick != null
? () => onClick?.call(tokenPosition)
: null,
child: HiddenText(
text: middle,
style: style,
),
),
child: CompositedTransformTarget(
link: overlayController == null || isTransitionAnimation
? LayerLinkAndKey(token.hashCode.toString()).link
: MatrixState.pAnyState
.layerLinkAndKey(token.text.uniqueKey)
.link,
child: Column(
key: overlayController == null || isTransitionAnimation
? null
: MatrixState.pAnyState
.layerLinkAndKey(token.text.uniqueKey)
.key,
children: [
MessageTokenButton(
token: token,
overlayController: overlayController,
textStyle: style,
width: tokenWidth,
animate: isTransitionAnimation,
activity: overlayController
?.toolbarMode.associatedActivityType !=
null
? overlayController?.messageAnalyticsEntry
?.activities(
overlayController!
.toolbarMode.associatedActivityType!,
)
: LinkifySpan(
text: middle,
style: style.merge(
TextStyle(
backgroundColor: backgroundColor,
),
),
linkStyle: TextStyle(
decoration: TextDecoration.underline,
color: linkColor,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
if (end.isNotEmpty)
LinkifySpan(
text: end,
style: style,
linkStyle: TextStyle(
decoration: TextDecoration.underline,
color: linkColor,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
],
.firstWhereOrNull((a) => a.tokens.contains(token))
: null,
),
),
MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onClick != null
? () => onClick?.call(tokenPosition)
: null,
child: RichText(
text: TextSpan(
children: [
if (start.isNotEmpty)
LinkifySpan(
text: start,
style: style,
linkStyle: TextStyle(
decoration: TextDecoration.underline,
color: linkColor,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
tokenPosition.hideContent
? WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GestureDetector(
onTap: onClick != null
? () => onClick?.call(tokenPosition)
: null,
child: HiddenText(
text: middle,
style: style,
),
),
)
: LinkifySpan(
text: middle,
// style: style.merge(
// TextStyle(
// backgroundColor: backgroundColor(tokenPosition)
// ),
// ),
style: style,
linkStyle: TextStyle(
decoration: TextDecoration.underline,
color: linkColor,
),
onOpen: (url) =>
UrlLauncher(context, url.url)
.launchUrl(),
),
if (end.isNotEmpty)
LinkifySpan(
text: end,
style: style,
linkStyle: TextStyle(
decoration: TextDecoration.underline,
color: linkColor,
),
onOpen: (url) =>
UrlLauncher(context, url.url).launchUrl(),
),
],
),
),
),
),
AnimatedContainer(
duration: const Duration(
milliseconds: AppConfig.overlayAnimationDuration,
),
height:
overlayController != null && !isTransitionAnimation
? 4
: 0,
width: tokenWidth,
child: Container(
color: backgroundColor(tokenPosition),
),
),
],
),
),
);

View file

@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/card_error_widget.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
@ -9,6 +7,7 @@ import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
class MessageTranslationCard extends StatefulWidget {
final PangeaMessageEvent messageEvent;
@ -28,7 +27,6 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
@override
void initState() {
debugPrint('MessageTranslationCard initState');
super.initState();
loadTranslation();
}
@ -99,42 +97,24 @@ class MessageTranslationCardState extends State<MessageTranslationCard> {
return const ToolbarContentLoadingIndicator();
}
return ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: AppConfig.toolbarMinWidth,
maxHeight: AppConfig.toolbarMaxHeight,
),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
repEvent!.text,
style: AppConfig.messageTextStyle(
widget.messageEvent.event,
Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
if (notGoingToTranslate &&
!InstructionsEnum.l1Translation.isToggledOff)
const Row(
mainAxisSize: MainAxisSize.min,
children: [
Expanded(
child: InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.l1Translation,
),
),
],
),
],
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.max,
children: [
Text(
repEvent!.text,
style: AppConfig.messageTextStyle(
widget.messageEvent.event,
Theme.of(context).colorScheme.primary,
),
textAlign: TextAlign.center,
),
),
if (notGoingToTranslate)
const InstructionsInlineTooltip(
instructionsEnum: InstructionsEnum.l1Translation,
),
],
);
}
}

View file

@ -2,93 +2,121 @@ import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/message_reactions.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/toolbar/widgets/measure_render_box.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_toolbar.dart';
import 'package:fluffychat/pangea/toolbar/widgets/overlay_message.dart';
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button_column.dart';
class OverlayCenterContent extends StatelessWidget {
final double messageHeight;
final double messageWidth;
final double maxWidth;
final Event event;
final Event? nextEvent;
final Event? prevEvent;
final bool hasReactions;
final bool shouldShowToolbarButtons;
final PangeaMessageEvent? pangeaMessageEvent;
final MessageOverlayController overlayController;
final ChatController chatController;
final Animation<Size>? sizeAnimation;
final void Function(RenderBox)? onChangeMessageSize;
final void Function(RenderBox)? onChangeContentSize;
final void Function(RenderBox)? onChangeButtonsSize;
final double messageHeight;
final double messageWidth;
final double toolbarMaxWidth;
final bool showToolbarButtons;
final bool hasReactions;
final bool isTransitionAnimation;
const OverlayCenterContent({
super.key,
required this.event,
required this.messageHeight,
required this.messageWidth,
required this.maxWidth,
required this.event,
required this.toolbarMaxWidth,
required this.overlayController,
required this.chatController,
required this.pangeaMessageEvent,
required this.nextEvent,
required this.prevEvent,
required this.showToolbarButtons,
required this.hasReactions,
required this.shouldShowToolbarButtons,
this.pangeaMessageEvent,
this.nextEvent,
this.prevEvent,
this.onChangeMessageSize,
this.onChangeContentSize,
this.onChangeButtonsSize,
this.sizeAnimation,
this.isTransitionAnimation = false,
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
constraints: BoxConstraints(maxWidth: maxWidth),
child: Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.max,
crossAxisAlignment: event.senderId == event.room.client.userID
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
if (pangeaMessageEvent != null &&
pangeaMessageEvent!.shouldShowToolbar)
ReadingAssistanceContentCard(
pangeaMessageEvent: pangeaMessageEvent!,
overlayController: overlayController,
),
const SizedBox(height: AppConfig.toolbarSpacing),
SizedBox(
height: messageHeight,
child: OverlayMessage(
event,
pangeaMessageEvent: pangeaMessageEvent,
immersionMode: chatController.choreographer.immersionMode,
controller: chatController,
overlayController: overlayController,
nextEvent: nextEvent,
prevEvent: prevEvent,
timeline: chatController.timeline!,
messageWidth: messageWidth,
messageHeight: messageHeight,
),
),
if (hasReactions)
Padding(
padding: const EdgeInsets.all(4),
child: SizedBox(
height: 20,
child: MessageReactions(
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
constraints: BoxConstraints(maxWidth: toolbarMaxWidth),
child: Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: event.senderId == event.room.client.userID
? CrossAxisAlignment.end
: CrossAxisAlignment.start,
children: [
MeasureRenderBox(
onChange: onChangeMessageSize,
child: OverlayMessage(
event,
chatController.timeline!,
pangeaMessageEvent: pangeaMessageEvent,
immersionMode: chatController.choreographer.immersionMode,
controller: chatController,
overlayController: overlayController,
nextEvent: nextEvent,
prevEvent: prevEvent,
timeline: chatController.timeline!,
sizeAnimation: sizeAnimation,
// there's a split seconds between when the transition animation starts and
// when the sizeAnimation is set when the original dimensions need to be enforced
messageWidth: sizeAnimation == null && isTransitionAnimation
? messageWidth
: null,
messageHeight:
sizeAnimation == null && isTransitionAnimation
? messageHeight
: null,
isTransitionAnimation: isTransitionAnimation,
),
),
),
],
if (hasReactions)
Padding(
padding: const EdgeInsets.all(4),
child: SizedBox(
height: 20,
child: MessageReactions(
event,
chatController.timeline!,
),
),
),
],
),
),
),
),
if (!isTransitionAnimation)
MeasureRenderBox(
onChange: onChangeButtonsSize,
child: ToolbarButtonRow(
event: event,
overlayController: overlayController,
shouldShowToolbarButtons: showToolbarButtons,
),
),
],
);
}
}

View file

@ -1,3 +1,4 @@
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
@ -22,8 +23,12 @@ class OverlayMessage extends StatelessWidget {
final Event? prevEvent;
final Timeline timeline;
final bool immersionMode;
final double messageWidth;
final double messageHeight;
final Animation<Size>? sizeAnimation;
final double? messageWidth;
final double? messageHeight;
final bool isTransitionAnimation;
const OverlayMessage(
this.event, {
@ -36,6 +41,8 @@ class OverlayMessage extends StatelessWidget {
this.pangeaMessageEvent,
this.nextEvent,
this.prevEvent,
this.sizeAnimation,
this.isTransitionAnimation = false,
super.key,
});
@ -112,127 +119,143 @@ class OverlayMessage extends StatelessWidget {
? ThemeData.dark().colorScheme.onPrimary
: theme.colorScheme.onSurface;
final content = SingleChildScrollView(
dragStartBehavior: DragStartBehavior.down,
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
padding: noBubble || noPadding
? EdgeInsets.zero
: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
width: messageWidth,
height: messageHeight,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (event.relationshipType == RelationshipTypes.reply)
FutureBuilder<Event?>(
future: event.getReplyEvent(
timeline,
),
builder: (
BuildContext context,
snapshot,
) {
final replyEvent = snapshot.hasData
? snapshot.data!
: Event(
eventId: event.relationshipEventId!,
content: {
'msgtype': 'm.text',
'body': '...',
},
senderId: event.senderId,
type: 'm.room.message',
room: event.room,
status: EventStatus.sent,
originServerTs: DateTime.now(),
);
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0,
),
child: InkWell(
borderRadius: ReplyContent.borderRadius,
onTap: () => controller.scrollToEventId(
replyEvent.eventId,
),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage: ownMessage,
timeline: timeline,
),
),
),
);
},
),
MessageContent(
event.getDisplayEvent(timeline),
textColor: textColor,
pangeaMessageEvent: pangeaMessageEvent,
immersionMode: immersionMode,
overlayController: overlayController,
controller: controller,
nextEvent: nextEvent,
prevEvent: prevEvent,
borderRadius: borderRadius,
timeline: timeline,
linkColor: theme.brightness == Brightness.light
? theme.colorScheme.primary
: ownMessage
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
isTransitionAnimation: isTransitionAnimation,
),
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
))
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
)) ...[
Icon(
Icons.edit_outlined,
color: textColor.withAlpha(164),
size: 14,
),
Text(
' - ${displayEvent.originServerTs.localizedTimeShort(context)}',
style: TextStyle(
color: textColor.withAlpha(
164,
),
fontSize: 12,
),
),
],
],
),
),
],
),
),
);
return Material(
color: color,
clipBehavior: Clip.antiAlias,
shape: RoundedRectangleBorder(
borderRadius: borderRadius,
),
child: SingleChildScrollView(
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
padding: noBubble || noPadding
? EdgeInsets.zero
: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
width: messageWidth,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: <Widget>[
if (event.relationshipType == RelationshipTypes.reply)
FutureBuilder<Event?>(
future: event.getReplyEvent(
timeline,
),
builder: (
BuildContext context,
snapshot,
) {
final replyEvent = snapshot.hasData
? snapshot.data!
: Event(
eventId: event.relationshipEventId!,
content: {
'msgtype': 'm.text',
'body': '...',
},
senderId: event.senderId,
type: 'm.room.message',
room: event.room,
status: EventStatus.sent,
originServerTs: DateTime.now(),
);
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0,
),
child: InkWell(
borderRadius: ReplyContent.borderRadius,
onTap: () => controller.scrollToEventId(
replyEvent.eventId,
),
child: AbsorbPointer(
child: ReplyContent(
replyEvent,
ownMessage: ownMessage,
timeline: timeline,
),
),
),
);
},
),
MessageContent(
event.getDisplayEvent(timeline),
textColor: textColor,
pangeaMessageEvent: pangeaMessageEvent,
immersionMode: immersionMode,
overlayController: overlayController,
controller: controller,
nextEvent: nextEvent,
prevEvent: prevEvent,
borderRadius: borderRadius,
timeline: timeline,
linkColor: theme.brightness == Brightness.light
? theme.colorScheme.primary
: ownMessage
? theme.colorScheme.onPrimary
: theme.colorScheme.onSurface,
),
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
))
Padding(
padding: const EdgeInsets.only(
top: 4.0,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (event.hasAggregatedEvents(
timeline,
RelationshipTypes.edit,
)) ...[
Icon(
Icons.edit_outlined,
color: textColor.withAlpha(164),
size: 14,
),
Text(
' - ${displayEvent.originServerTs.localizedTimeShort(context)}',
style: TextStyle(
color: textColor.withAlpha(
164,
),
fontSize: 12,
),
),
],
],
),
),
],
),
),
),
child: sizeAnimation != null
? AnimatedBuilder(
animation: sizeAnimation!,
builder: (context, child) {
return SizedBox(
height: sizeAnimation!.value.height,
width: sizeAnimation!.value.width,
child: content,
);
},
)
: content,
);
}
}

View file

@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/emojis/emoji_stack.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart';
@ -19,10 +20,10 @@ class EmojiPracticeButton extends StatelessWidget {
Widget build(BuildContext context) {
final emoji = token.getEmoji();
return WordZoomActivityButton(
icon: emoji == null
icon: emoji.isEmpty
? const Icon(Icons.add_reaction_outlined)
: Text(
emoji,
: EmojiStack(
emoji: emoji,
style: const TextStyle(fontSize: 24),
),
isSelected: isSelected,

View file

@ -1,24 +1,22 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
/// The multiple choice activity view
class MultipleChoiceActivity extends StatefulWidget {
@ -122,13 +120,15 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
// If the selected choice is correct, send the record
if (widget.currentActivity.content.isCorrect(value, index)) {
// If the activity is an emoji activity, set the emoji value
if (widget.currentActivity.activityType == ActivityTypeEnum.emoji) {
if (widget.currentActivity.targetTokens?.length != 1) {
debugger(when: kDebugMode);
} else {
widget.currentActivity.targetTokens!.first.setEmoji(value);
}
}
// TODO: this widget is deprecated for use with emoji activities
// if (widget.currentActivity.activityType == ActivityTypeEnum.emoji) {
// if (widget.currentActivity.targetTokens?.length != 1) {
// debugger(when: kDebugMode);
// } else {
// widget.currentActivity.targetTokens!.first.setEmoji(value);
// }
// }
// The next entry in the analytics stream should be from the above putAnalytics.setState.
// So we can wait for the stream to update before calling onActivityFinish.
@ -219,7 +219,6 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
messageEvent:
widget.practiceCardController.widget.pangeaMessageEvent,
overlayController: widget.overlayController,
tts: tts,
setIsPlayingAudio: widget.overlayController.setIsPlayingAudio,
onError: widget.onError,
),

View file

@ -1,31 +1,30 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/gain_points_animation.dart';
import 'package:fluffychat/pangea/analytics_misc/message_analytics_controller.dart';
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/choreographer/widgets/igc/card_error_widget.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_repo.dart';
import 'package:fluffychat/pangea/practice_activities/target_tokens_and_activity_type.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/toolbar/models/message_activity_request.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart';
import 'package:fluffychat/pangea/toolbar/models/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/toolbar/repo/practice_repo.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart';
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/foundation.dart';
import 'package:flutter/material.dart';
/// The wrapper for practice activity content.
/// Handles the activities associated with a message,
@ -261,7 +260,8 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
// wait for savor the joy before popping from the activity queue
// to keep the completed activity on screen for a moment
widget.overlayController.onActivityFinish(currentActivity!.activityType);
widget.overlayController
.onActivityFinish(currentActivity!.activityType, null);
widget.overlayController.widget.chatController.choreographer.tts.stop();
} catch (e, s) {
_onError();
@ -298,18 +298,19 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.morphId:
case ActivityTypeEnum.messageMeaning:
final selectedChoice =
currentActivity?.activityType == ActivityTypeEnum.emoji &&
(currentActivity?.targetTokens?.isNotEmpty ?? false)
? currentActivity?.targetTokens?.first.getEmoji()
: null;
// final selectedChoice =
// currentActivity?.activityType == ActivityTypeEnum.emoji &&
// (currentActivity?.targetTokens?.isNotEmpty ?? false)
// ? currentActivity?.targetTokens?.first.getEmoji()
// : null;
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity!,
event: widget.pangeaMessageEvent.event,
onError: _onError,
overlayController: widget.overlayController,
initialSelectedChoice: selectedChoice,
// initialSelectedChoice: selectedChoice,
initialSelectedChoice: null,
clearResponsesOnUpdate:
currentActivity?.activityType == ActivityTypeEnum.emoji,
);
@ -354,6 +355,15 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
: null,
),
],
Positioned(
left: 0,
top: 0,
child: IconButton(
onPressed: () => widget.overlayController
.updateToolbarMode(MessageMode.wordEmoji),
icon: const Icon(Icons.lightbulb),
),
),
// Flag button in the top right corner
// Positioned(
// top: 0,

View file

@ -1,19 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class WordAudioButton extends StatefulWidget {
final String text;
final double size;
final bool isSelected;
final double baseOpacity;
/// If defined, this callback will be called instead of the default one
final void Function()? callbackOverride;
const WordAudioButton({
super.key,
required this.text,
this.size = 24,
this.isSelected = false,
this.baseOpacity = 1,
this.callbackOverride,
});
@override
@ -24,6 +30,15 @@ class WordAudioButtonState extends State<WordAudioButton> {
final TtsController tts = TtsController();
bool _isPlaying = false;
@override
void didUpdateWidget(covariant WordAudioButton oldWidget) {
if (oldWidget.isSelected != widget.isSelected ||
oldWidget.callbackOverride != widget.callbackOverride) {
setState(() {});
}
super.didUpdateWidget(oldWidget);
}
@override
void dispose() {
tts.dispose();
@ -33,47 +48,56 @@ class WordAudioButtonState extends State<WordAudioButton> {
@override
Widget build(BuildContext context) {
return CompositedTransformTarget(
link: MatrixState.pAnyState.layerLinkAndKey('word-audio-button').link,
child: IconButton(
key: MatrixState.pAnyState.layerLinkAndKey('word-audio-button').key,
icon: const Icon(Icons.play_arrow_outlined),
isSelected: _isPlaying,
selectedIcon: const Icon(Icons.pause_outlined),
color: _isPlaying ? Colors.white : null,
tooltip:
_isPlaying ? L10n.of(context).stop : L10n.of(context).playAudio,
iconSize: widget.size,
onPressed: () async {
if (_isPlaying) {
await tts.stop();
if (mounted) {
setState(() => _isPlaying = false);
}
} else {
if (mounted) {
setState(() => _isPlaying = true);
}
try {
await tts.tryToSpeak(
widget.text,
context,
targetID: 'word-audio-button',
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"text": widget.text,
},
);
} finally {
if (mounted) {
setState(() => _isPlaying = false);
}
}
}
}, // Disable button if language isn't supported
link: MatrixState.pAnyState
.layerLinkAndKey('word-audio-butto${widget.text}')
.link,
child: Opacity(
opacity: !widget.isSelected ? widget.baseOpacity : 1,
child: IconButton(
key: MatrixState.pAnyState
.layerLinkAndKey('word-audio-butto${widget.text}')
.key,
icon: const Icon(Icons.volume_up),
isSelected: _isPlaying,
selectedIcon: const Icon(Icons.pause_outlined),
color:
widget.isSelected ? Theme.of(context).colorScheme.primary : null,
tooltip:
_isPlaying ? L10n.of(context).stop : L10n.of(context).playAudio,
iconSize: widget.size,
onPressed: widget.callbackOverride ??
() async {
if (_isPlaying) {
await tts.stop();
if (mounted) {
setState(() => _isPlaying = false);
}
} else {
if (mounted) {
setState(() => _isPlaying = true);
}
try {
await tts.tryToSpeak(
widget.text,
context,
targetID: 'word-audio-button',
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"text": widget.text,
},
);
} finally {
if (mounted) {
setState(() => _isPlaying = false);
}
}
}
}, // Disable button if language isn't supported
),
),
);
}

View file

@ -113,7 +113,7 @@ class WordAudioButtonState extends State<WordTextWithAudioButton> {
widget.text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: _isPlaying
? Theme.of(context).colorScheme.secondary
? Theme.of(context).colorScheme.primary
: null,
fontSize: textSize,
),
@ -136,8 +136,11 @@ class WordAudioButtonState extends State<WordTextWithAudioButton> {
)
else
Icon(
_isPlaying ? Icons.play_arrow : Icons.play_arrow_outlined,
_isPlaying ? Icons.volume_up : Icons.pause_outlined,
size: textSize,
color: _isPlaying
? Theme.of(context).colorScheme.primary
: null,
),
],
),

View file

@ -24,11 +24,12 @@ class WordZoomActivityButton extends StatelessWidget {
icon: icon,
iconSize: 24,
color: isSelected ? Theme.of(context).colorScheme.primary : null,
style: IconButton.styleFrom(
backgroundColor: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.25)
: Colors.transparent,
),
visualDensity: VisualDensity.compact,
// style: IconButton.styleFrom(
// backgroundColor: isSelected
// ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.25)
// : Colors.transparent,
// ),
);
if (opacity != null) {

View file

@ -1,40 +1,42 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.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/pangea/analytics_misc/put_analytics_controller.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_meaning_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_mode_locked_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_speech_to_text_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_translation_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_unsubscribed_card.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart';
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:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix_api_lite/model/message_types.dart';
const double minCardHeight = 70;
class ReadingAssistanceContentCard extends StatelessWidget {
class ReadingAssistanceContent extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController;
final Duration animationDuration;
const ReadingAssistanceContentCard({
const ReadingAssistanceContent({
super.key,
required this.pangeaMessageEvent,
required this.overlayController,
this.animationDuration = FluffyThemes.animationDuration,
});
@override
ReadingAssistanceContentState createState() =>
ReadingAssistanceContentState();
}
class ReadingAssistanceContentState extends State<ReadingAssistanceContent> {
TtsController get ttsController =>
overlayController.widget.chatController.choreographer.tts;
widget.overlayController.widget.chatController.choreographer.tts;
Widget? toolbarContent(BuildContext context) {
final bool? subscribed =
@ -42,69 +44,76 @@ class ReadingAssistanceContentCard extends StatelessWidget {
if (subscribed != null && !subscribed) {
return MessageUnsubscribedCard(
controller: overlayController,
controller: widget.overlayController,
);
}
if ((overlayController.messageAnalyticsEntry?.hasHiddenWordActivity ??
false) ||
(overlayController.messageAnalyticsEntry?.hasMessageMeaningActivity ??
false)) {
if (widget.overlayController.messageAnalyticsEntry?.hasHiddenWordActivity ??
false) {
return PracticeActivityCard(
pangeaMessageEvent: pangeaMessageEvent,
overlayController: overlayController,
targetTokensAndActivityType:
overlayController.messageAnalyticsEntry!.nextActivity!,
pangeaMessageEvent: widget.pangeaMessageEvent,
overlayController: widget.overlayController,
targetTokensAndActivityType: widget
.overlayController.messageAnalyticsEntry!
.nextActivity(ActivityTypeEnum.hiddenWordListening)!,
location: AnalyticsUpdateOrigin.practiceActivity,
);
}
if (!overlayController.initialized) {
if (widget.overlayController.messageAnalyticsEntry
?.hasMessageMeaningActivity ??
false) {
return PracticeActivityCard(
pangeaMessageEvent: widget.pangeaMessageEvent,
overlayController: widget.overlayController,
targetTokensAndActivityType: widget
.overlayController.messageAnalyticsEntry!
.nextActivity(ActivityTypeEnum.messageMeaning)!,
location: AnalyticsUpdateOrigin.practiceActivity,
);
}
if (!widget.overlayController.initialized) {
return const ToolbarContentLoadingIndicator();
}
final unlocked = overlayController.toolbarMode.isUnlocked(
overlayController.pangeaMessageEvent!.proportionOfActivitiesCompleted,
overlayController.isPracticeComplete,
);
// final unlocked = widget.overlayController.toolbarMode
// .isUnlocked(widget.overlayController);
if (!unlocked) {
return MessageModeLockedCard(controller: overlayController);
}
// if (!unlocked) {
// return MessageModeLockedCard(controller: widget.overlayController);
// }
switch (overlayController.toolbarMode) {
switch (widget.overlayController.toolbarMode) {
case MessageMode.messageTranslation:
return MessageTranslationCard(
messageEvent: pangeaMessageEvent,
);
case MessageMode.messageTextToSpeech:
return MessageAudioCard(
messageEvent: pangeaMessageEvent,
overlayController: overlayController,
selection: overlayController.selectedSpan,
tts: ttsController,
setIsPlayingAudio: overlayController.setIsPlayingAudio,
);
// return MessageTranslationCard(
// messageEvent: widget.pangeaMessageEvent,
// );
case MessageMode.messageSpeechToText:
return MessageSpeechToTextCard(
messageEvent: pangeaMessageEvent,
);
// return MessageSpeechToTextCard(
// messageEvent: widget.pangeaMessageEvent,
// );
case MessageMode.noneSelected:
return Padding(
padding: const EdgeInsets.all(8),
child: Text(
L10n.of(context).clickWordsInstructions,
textAlign: TextAlign.center,
),
);
// return Padding(
// padding: const EdgeInsets.all(8),
// child: Text(
// L10n.of(context).clickWordsInstructions,
// textAlign: TextAlign.center,
// ),
// );
case MessageMode.messageMeaning:
return MessageMeaningCard(controller: overlayController);
// return MessageMeaningCard(controller: widget.overlayController);
case MessageMode.listening:
// return MessageAudioCard(
// messageEvent: widget.overlayController.pangeaMessageEvent!,
// overlayController: widget.overlayController,
// setIsPlayingAudio: widget.overlayController.setIsPlayingAudio);
case MessageMode.practiceActivity:
case MessageMode.wordZoom:
case MessageMode.wordEmoji:
case MessageMode.wordMorph:
case MessageMode.wordMeaning:
if (overlayController.selectedToken == null) {
if (widget.overlayController.selectedToken == null) {
return Padding(
padding: const EdgeInsets.all(16),
child: Text(
@ -114,10 +123,10 @@ class ReadingAssistanceContentCard extends StatelessWidget {
);
}
return WordZoomWidget(
token: overlayController.selectedToken!,
messageEvent: overlayController.pangeaMessageEvent!,
token: widget.overlayController.selectedToken!,
messageEvent: widget.overlayController.pangeaMessageEvent!,
tts: ttsController,
overlayController: overlayController,
overlayController: widget.overlayController,
);
}
}
@ -125,7 +134,7 @@ class ReadingAssistanceContentCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (![MessageTypes.Text, MessageTypes.Audio].contains(
pangeaMessageEvent.event.messageType,
widget.pangeaMessageEvent.event.messageType,
)) {
return const SizedBox();
}
@ -149,7 +158,7 @@ class ReadingAssistanceContentCard extends StatelessWidget {
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedSize(
duration: FluffyThemes.animationDuration,
duration: widget.animationDuration,
child: toolbarContent(context),
),
],

View file

@ -1,9 +1,8 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:flutter/material.dart';
class ToolbarButton extends StatelessWidget {
final MessageMode mode;
@ -21,15 +20,12 @@ class ToolbarButton extends StatelessWidget {
Color color(BuildContext context) => mode.iconButtonColor(
context,
overlayController.toolbarMode,
overlayController.pangeaMessageEvent!.proportionOfActivitiesCompleted,
overlayController.isPracticeComplete,
overlayController,
);
bool get enabled => mode.isUnlocked(
overlayController.pangeaMessageEvent!.proportionOfActivitiesCompleted,
overlayController.isPracticeComplete,
);
bool get enabled => mode == MessageMode.messageTranslation
? overlayController.isTranslationUnlocked
: true;
@override
Widget build(BuildContext context) {
@ -37,7 +33,7 @@ class ToolbarButton extends StatelessWidget {
message: mode.tooltip(context),
child: PressableButton(
borderRadius: BorderRadius.circular(20),
depressed: mode == overlayController.toolbarMode || !enabled,
depressed: mode == overlayController.toolbarMode,
color: color(context),
onPressed: () => onPressed(mode),
playSound: true,

View file

@ -8,19 +8,16 @@ import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.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/toolbar_button.dart';
class ToolbarButtonAndProgressColumn extends StatelessWidget {
final Event event;
final MessageOverlayController overlayController;
final bool shouldShowToolbarButtons;
final double height;
final double width;
const ToolbarButtonAndProgressColumn({
required this.event,
required this.overlayController,
required this.shouldShowToolbarButtons,
required this.height,
required this.width,
super.key,
@ -37,7 +34,6 @@ class ToolbarButtonAndProgressColumn extends StatelessWidget {
@override
Widget build(BuildContext context) {
if (event.messageType == MessageTypes.Audio ||
!shouldShowToolbarButtons ||
!(overlayController.pangeaMessageEvent?.messageDisplayLangIsL2 ??
false)) {
return SizedBox(height: height, width: width);
@ -77,8 +73,9 @@ class ToolbarButtonAndProgressColumn extends StatelessWidget {
margin: barMargin,
),
Positioned(
bottom:
height * MessageMode.messageMeaning.pointOnBar - buttonSize,
bottom: height * MessageMode.noneSelected.pointOnBar -
buttonSize / 2 -
barMargin.vertical / 2,
child: Container(
decoration: BoxDecoration(
color: overlayController.isPracticeComplete
@ -98,36 +95,6 @@ class ToolbarButtonAndProgressColumn extends StatelessWidget {
),
),
),
Positioned(
bottom: height * MessageMode.messageTranslation.pointOnBar -
buttonSize / 2,
child: ToolbarButton(
mode: MessageMode.messageTranslation,
overlayController: overlayController,
onPressed: overlayController.updateToolbarMode,
buttonSize: buttonSize,
),
),
Positioned(
bottom: height * MessageMode.messageTextToSpeech.pointOnBar -
buttonSize / 2,
child: ToolbarButton(
mode: MessageMode.messageTextToSpeech,
overlayController: overlayController,
onPressed: overlayController.updateToolbarMode,
buttonSize: buttonSize,
),
),
Positioned(
bottom: height * MessageMode.practiceActivity.pointOnBar,
child: ToolbarButton(
mode: MessageMode.practiceActivity,
overlayController: overlayController,
onPressed: (mode) =>
overlayController.onNextActivityRequest(),
buttonSize: buttonSize,
),
),
],
),
],

View file

@ -0,0 +1,85 @@
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/toolbar_button.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
class ToolbarButtonRow extends StatelessWidget {
final Event event;
final MessageOverlayController overlayController;
final bool shouldShowToolbarButtons;
const ToolbarButtonRow({
required this.event,
required this.overlayController,
required this.shouldShowToolbarButtons,
super.key,
});
static const double iconWidth = 36.0;
static const double buttonSize = 40.0;
static const barMargin =
EdgeInsets.symmetric(horizontal: iconWidth / 2, vertical: buttonSize / 2);
@override
Widget build(BuildContext context) {
if (event.messageType == MessageTypes.Audio ||
!shouldShowToolbarButtons ||
!(overlayController.pangeaMessageEvent?.messageDisplayLangIsL2 ??
false)) {
return const SizedBox(
height: 50.0,
);
}
return Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
ToolbarButton(
mode: MessageMode.messageTranslation,
overlayController: overlayController,
onPressed: overlayController.updateToolbarMode,
buttonSize: buttonSize,
),
],
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
spacing: 4.0,
children: [
ToolbarButton(
mode: MessageMode.wordMorph,
overlayController: overlayController,
onPressed: overlayController.updateToolbarMode,
buttonSize: buttonSize,
),
ToolbarButton(
mode: MessageMode.wordMeaning,
overlayController: overlayController,
onPressed: overlayController.updateToolbarMode,
buttonSize: buttonSize,
),
ToolbarButton(
mode: MessageMode.listening,
overlayController: overlayController,
onPressed: overlayController.updateToolbarMode,
buttonSize: buttonSize,
),
ToolbarButton(
mode: MessageMode.wordEmoji,
overlayController: overlayController,
onPressed: overlayController.updateToolbarMode,
buttonSize: buttonSize,
),
],
),
],
);
}
}

View file

@ -1,11 +1,5 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/text_loading_shimmer.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
@ -13,11 +7,15 @@ import 'package:fluffychat/pangea/learning_settings/constants/language_constants
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.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/practice_activity/word_zoom_activity_button.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:material_symbols_icons/symbols.dart';
class LemmaMeaningWidget extends StatefulWidget {
final ConstructUses constructUse;
@ -27,7 +25,7 @@ class LemmaMeaningWidget extends StatefulWidget {
/// These are not present if this widget is used outside the chat
/// (e.g. in the vocab details view)
/// we're going to punt on letting the user assign the meaning in the vocab details view
/// TODO: let the user assign the meaning in the vocab details view
final MessageOverlayController? controller;
final PangeaToken? token;
@ -48,6 +46,9 @@ class LemmaMeaningWidget extends StatefulWidget {
class LemmaMeaningWidgetState extends State<LemmaMeaningWidget> {
bool _editMode = false;
late TextEditingController _controller;
LemmaInfoResponse? _lemmaInfo;
bool _isLoading = true;
String? _error;
String get _lemma => widget.constructUse.lemma;
@ -55,6 +56,16 @@ class LemmaMeaningWidgetState extends State<LemmaMeaningWidget> {
void initState() {
super.initState();
_controller = TextEditingController();
_fetchLemmaMeaning();
}
@override
void didUpdateWidget(covariant LemmaMeaningWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.constructUse != widget.constructUse ||
oldWidget.langCode != widget.langCode) {
_fetchLemmaMeaning();
}
}
@override
@ -74,29 +85,42 @@ class LemmaMeaningWidgetState extends State<LemmaMeaningWidget> {
LanguageKeys.defaultLanguage,
);
Future<LemmaInfoResponse> _lemmaMeaning() => LemmaInfoRepo.get(_request);
Future<void> _fetchLemmaMeaning() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
_lemmaInfo = await LemmaInfoRepo.get(_request);
} catch (e) {
_error = e.toString();
} finally {
if (mounted) setState(() => _isLoading = false);
}
}
void _toggleEditMode(bool value) => setState(() => _editMode = value);
Future<void> editLemmaMeaning(String userEdit) async {
final originalMeaning = await _lemmaMeaning();
final originalMeaning = _lemmaInfo;
LemmaInfoRepo.set(
_request,
LemmaInfoResponse(emoji: originalMeaning.emoji, meaning: userEdit),
);
if (originalMeaning != null) {
LemmaInfoRepo.set(
_request,
LemmaInfoResponse(emoji: originalMeaning.emoji, meaning: userEdit),
);
_toggleEditMode(false);
_toggleEditMode(false);
_fetchLemmaMeaning();
}
}
@override
Widget build(BuildContext context) {
if (widget.token != null &&
widget.token!.shouldDoActivity(
a: ActivityTypeEnum.wordMeaning,
feature: null,
tag: null,
)) {
widget.controller?.messageAnalyticsEntry != null &&
widget.controller!.messageAnalyticsEntry!
.hasActivity(ActivityTypeEnum.wordMeaning, widget.token!)) {
return WordZoomActivityButton(
icon: const Icon(Symbols.dictionary),
isSelected: widget.controller?.toolbarMode == MessageMode.wordMeaning,
@ -106,107 +130,110 @@ class LemmaMeaningWidgetState extends State<LemmaMeaningWidget> {
widget.controller!.updateToolbarMode(MessageMode.wordMeaning);
}
: () => {},
opacity:
widget.controller?.toolbarMode == MessageMode.wordMeaning ? 1 : 0.4,
);
}
return FutureBuilder<LemmaInfoResponse>(
future: _lemmaMeaning(),
builder: (context, snapshot) {
if (_editMode) {
_controller.text = snapshot.data?.meaning ?? "";
return Column(
mainAxisSize: MainAxisSize.min,
if (_isLoading) {
return const TextLoadingShimmer();
}
if (_error != null) {
debugger(when: kDebugMode);
return Text(
L10n.of(context).oopsSomethingWentWrong,
textAlign: TextAlign.center,
);
}
if (_editMode) {
_controller.text = _lemmaInfo?.meaning ?? "";
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
"${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning(_lemma, widget.constructUse.category)}",
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
minLines: 1,
maxLines: 3,
controller: _controller,
decoration: InputDecoration(
hintText: _lemmaInfo?.meaning,
),
),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
"${L10n.of(context).pangeaBotIsFallible} ${L10n.of(context).whatIsMeaning(_lemma, widget.constructUse.category)}",
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TextField(
minLines: 1,
maxLines: 3,
controller: _controller,
decoration: InputDecoration(
hintText: snapshot.data!.meaning,
ElevatedButton(
onPressed: () => _toggleEditMode(false),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(L10n.of(context).cancel),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () => _toggleEditMode(false),
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(L10n.of(context).cancel),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () => _controller.text != _lemmaInfo?.meaning &&
_controller.text.isNotEmpty
? editLemmaMeaning(_controller.text)
: null,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
const SizedBox(width: 10),
ElevatedButton(
onPressed: () =>
_controller.text != snapshot.data!.meaning &&
_controller.text.isNotEmpty
? editLemmaMeaning(_controller.text)
: null,
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10.0),
),
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(L10n.of(context).saveChanges),
),
],
padding: const EdgeInsets.symmetric(horizontal: 10),
),
child: Text(L10n.of(context).saveChanges),
),
],
);
}
),
],
);
}
if (snapshot.connectionState != ConnectionState.done) {
return const TextLoadingShimmer();
}
if (snapshot.hasError || snapshot.data == null) {
debugger(when: kDebugMode);
return Text(
L10n.of(context).oopsSomethingWentWrong,
textAlign: TextAlign.center,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: () => _toggleEditMode(true),
onDoubleTap: () => _toggleEditMode(true),
child: RichText(
text: TextSpan(
style: widget.style,
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) const TextSpan(text: ' '),
TextSpan(text: snapshot.data!.meaning),
],
),
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: () => _toggleEditMode(true),
onDoubleTap: () => _toggleEditMode(true),
child: RichText(
text: TextSpan(
style: widget.style?.copyWith(
color: widget.controller?.toolbarMode ==
MessageMode.wordMeaning
? Theme.of(context).colorScheme.primary
: null,
),
children: [
if (widget.leading != null) widget.leading!,
if (widget.leading != null) const TextSpan(text: ' '),
TextSpan(
text: _lemmaInfo?.meaning,
),
],
),
),
),
],
);
},
),
),
],
);
}
}

View file

@ -1,7 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
@ -9,8 +5,12 @@ 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/toolbar/controllers/tts_controller.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_text_with_audio_button.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/practice_activity/word_audio_button.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class LemmaWidget extends StatefulWidget {
final PangeaToken token;
@ -18,6 +18,7 @@ class LemmaWidget extends StatefulWidget {
final VoidCallback onEdit;
final VoidCallback onEditDone;
final TtsController tts;
final MessageOverlayController? overlayController;
const LemmaWidget({
super.key,
@ -26,6 +27,7 @@ class LemmaWidget extends StatefulWidget {
required this.onEdit,
required this.onEditDone,
required this.tts,
required this.overlayController,
});
@override
@ -162,16 +164,39 @@ class LemmaWidgetState extends State<LemmaWidget> {
);
}
return Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: () => _toggleEditMode(true),
onDoubleTap: () => _toggleEditMode(true),
child: WordTextWithAudioButton(
text: widget.token.lemma.text,
return Row(
children: [
Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: () => _toggleEditMode(true),
onDoubleTap: () => _toggleEditMode(true),
child: Text(
widget.token.lemma.text,
style: Theme.of(context).textTheme.headlineSmall,
overflow: TextOverflow.ellipsis,
),
),
),
),
if (widget.token.lemma.text.toLowerCase() ==
widget.token.text.content.toLowerCase())
WordAudioButton(
text: widget.token.text.content,
isSelected:
MessageMode.listening == widget.overlayController?.toolbarMode,
baseOpacity: 0.4,
callbackOverride:
widget.overlayController?.messageAnalyticsEntry?.hasActivity(
MessageMode.listening.associatedActivityType!,
widget.token,
) ==
true
? () => widget.overlayController
?.updateToolbarMode(MessageMode.listening)
: null,
),
],
);
}
}

View file

@ -1,11 +1,8 @@
// 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/analytics_details_popup/analytics_details_popup.dart';
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
@ -19,10 +16,13 @@ import 'package:fluffychat/pangea/lemmas/construct_xp_widget.dart';
import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_feature_display.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_repo.dart';
import 'package:fluffychat/pangea/morphs/morph_tag_display.dart';
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class MorphFocusWidget extends StatefulWidget {
final PangeaToken token;
@ -54,17 +54,18 @@ class MorphFocusWidgetState extends State<MorphFocusWidget> {
final ScrollController _scrollController = ScrollController();
void resetMorphTag() => setState(
() => selectedMorphTag = widget.token.morph[widget.morphFeature]!,
() => selectedMorphTag =
widget.token.getMorphTag(widget.morphFeature) ?? "X",
);
@override
void didUpdateWidget(MorphFocusWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.token != oldWidget.token ||
widget.morphFeature != oldWidget.morphFeature) {
resetMorphTag();
setState(() => editMode = false);
}
super.didUpdateWidget(oldWidget);
}
@override
@ -171,40 +172,51 @@ class MorphFocusWidgetState extends State<MorphFocusWidget> {
children: [
MorphFeatureDisplay(
morphFeature: widget.morphFeature,
morphTag: selectedMorphTag,
),
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: enterEditMode,
onDoubleTap: enterEditMode,
child: MorphTagDisplay(
morphFeature: widget.morphFeature,
textColor: Theme.of(context).brightness ==
Brightness.light
? id.constructUses.lemmaCategory.darkColor(context)
: id.constructUses.lemmaCategory.color(context),
if (widget.token.getMorphTag(widget.morphFeature) != null) ...[
Row(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Tooltip(
triggerMode: TooltipTriggerMode.tap,
message: L10n.of(context).doubleClickToEdit,
child: GestureDetector(
onLongPress: enterEditMode,
onDoubleTap: enterEditMode,
child: MorphTagDisplay(
morphFeature: MorphFeaturesEnumExtension.fromString(
widget.morphFeature,
),
morphTag:
widget.token.getMorphTag(widget.morphFeature) ??
L10n.of(context).nan,
textColor: Theme.of(context).brightness ==
Brightness.light
? id.constructUses.lemmaCategory.darkColor(context)
: id.constructUses.lemmaCategory.color(context),
),
),
),
),
const SizedBox(width: 6),
ConstructXpWidget(
id: id,
onTap: () => showDialog<AnalyticsPopupWrapper>(
context: context,
builder: (context) => AnalyticsPopupWrapper(
constructZoom: id,
view: ConstructTypeEnum.morph,
const SizedBox(width: 6),
ConstructXpWidget(
id: id,
onTap: () => showDialog<AnalyticsPopupWrapper>(
context: context,
builder: (context) => AnalyticsPopupWrapper(
constructZoom: id,
view: ConstructTypeEnum.morph,
),
),
),
),
],
),
],
),
MorphMeaningWidget(
feature: widget.morphFeature,
tag: widget.token.getMorphTag(widget.morphFeature)!,
),
] else
Text(L10n.of(context).nan),
],
),
);
@ -220,91 +232,72 @@ class MorphFocusWidgetState extends State<MorphFocusWidget> {
textAlign: TextAlign.center,
style: const TextStyle(fontStyle: FontStyle.italic),
),
Container(
constraints: const BoxConstraints(maxHeight: 50),
child: Scrollbar(
controller: _scrollController,
thumbVisibility: true,
child: SingleChildScrollView(
controller: _scrollController,
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: FutureBuilder(
future: MorphsRepo.get(),
builder: (context, snapshot) {
final allMorphTagsForEdit = snapshot.data
?.getDisplayTags(widget.morphFeature) ??
defaultMorphMapping
.getDisplayTags(widget.morphFeature);
FutureBuilder(
future: MorphsRepo.get(),
builder: (context, snapshot) {
final allMorphTagsForEdit =
snapshot.data?.getDisplayTags(widget.morphFeature) ??
defaultMorphMapping.getDisplayTags(widget.morphFeature);
return snapshot.connectionState == ConnectionState.done
? Row(
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:
WidgetStateProperty.all<Color>(
selectedMorphTag == tag
? Theme.of(context)
.colorScheme
.primary
.withAlpha(50)
: Colors.transparent,
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(10),
),
),
),
onPressed: () {
setState(() => selectedMorphTag = tag);
},
child: Text(
getGrammarCopy(
category: widget.morphFeature,
lemma: tag,
context: context,
) ??
tag,
textAlign: TextAlign.center,
),
),
);
}).toList(),
)
: const Center(
child: CircularProgressIndicator(),
);
},
),
),
),
),
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
}
return Wrap(
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: WidgetStateProperty.all<Color>(
selectedMorphTag == tag
? Theme.of(context)
.colorScheme
.primary
.withAlpha(50)
: Colors.transparent,
),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
),
),
onPressed: () {
setState(() => selectedMorphTag = tag);
},
child: Text(
getGrammarCopy(
category: widget.morphFeature,
lemma: tag,
context: context,
) ??
tag,
textAlign: TextAlign.center,
),
),
);
}).toList(),
);
},
),
Row(
mainAxisAlignment: MainAxisAlignment.center,

View file

@ -1,59 +1,74 @@
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.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/practice_activity/word_zoom_activity_button.dart';
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_categories_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_zoom_activity_button.dart';
class MorphologicalListItem extends StatelessWidget {
final Function(String) onPressed;
final String morphFeature;
final String morphTag;
final bool isUnlocked;
final bool isSelected;
final MorphFeaturesEnum morphFeature;
final PangeaToken token;
final MessageOverlayController overlayController;
const MorphologicalListItem({
required this.onPressed,
required this.morphFeature,
required this.morphTag,
this.isUnlocked = true,
this.isSelected = false,
required this.token,
required this.overlayController,
super.key,
});
bool get shouldDoActivity =>
overlayController.messageAnalyticsEntry?.hasActivity(
ActivityTypeEnum.morphId,
token,
morphFeature,
) ==
true;
bool get isSelected => overlayController.toolbarMode == MessageMode.wordMorph;
String get morphTag => token.getMorphTag(morphFeature.name) ?? "X";
@override
Widget build(BuildContext context) {
return SizedBox(
width: 40,
height: 40,
child: WordZoomActivityButton(
icon: isUnlocked
// if unlocked, we show the specific icon for the morphological feature/tag
? MorphIcon(morphFeature: morphFeature, morphTag: morphTag)
// we show general feature icon if the morph is locked
// : MorphIcon(morphFeature: morphFeature, morphTag: null),
// or maybe we should the general grammar icon to show the
// connection between these and the grammar icon in the progress header
: const Icon(Symbols.toys_and_games),
icon: shouldDoActivity
? const Icon(Symbols.toys_and_games)
: MorphIcon(
morphFeature: morphFeature,
morphTag: token.getMorphTag(morphFeature.name),
size: const Size(24, 24),
),
isSelected: isSelected,
onPressed: () => onPressed(morphFeature),
tooltip: isUnlocked
? getGrammarCopy(
category: morphFeature,
// onPressed: shouldDoActivity
// ? () => overlayController.updateToolbarMode(MessageMode.wordMorph)
// : () => (feature) => showDialog<AnalyticsPopupWrapper>(
// context: context,
// builder: (context) => AnalyticsPopupWrapper(
// constructZoom: token.morphIdByFeature(feature),
// view: ConstructTypeEnum.vocab,
// ),
// ),
onPressed: () =>
overlayController.onMorphActivitySelect(token, morphFeature),
tooltip: shouldDoActivity
? morphFeature.getDisplayCopy(context)
: getGrammarCopy(
category: morphFeature.name,
lemma: morphTag,
context: context,
)
: getMorphologicalCategoryCopy(
morphFeature,
context,
),
opacity: isSelected
? 1
: !isUnlocked
? 0.2
: shouldDoActivity
? 0.4
: 1,
),
);

View file

@ -1,4 +1,4 @@
import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
enum WordZoomSelection {
meaning,

Some files were not shown because too many files have changed in this diff Show more