diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index a56697679..c995f637a 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4660,6 +4660,16 @@ "publicProfileTitle": "Allow my profile to be found in search", "publicProfileDesc": "By enabling this option, I confirm that I am of legal age in my country of residence", "clickWordsInstructions": "Click on individual words for more activities.", + "chooseBestDefinition": "Choose the best definition", + "meaningSectionHeader": "Meaning:", + "formSectionHeader": "Forms used in chats:", + "noEmojiSelectedTooltip": "No emoji selected", + "writingExercisesTooltip": "Writing exercises", + "listeningExercisesTooltip": "Listening exercises", + "readingExercisesTooltip": "Reading exercises", + "meaningNotFound": "Meaning could not be found.", + "formsNotFound": "Forms could not be found.", + "chooseBestDefinition": "What does this word mean?", "chooseBaseForm": "Choose the base form", "notTheCodeError": "Sorry, that's not the code!", "totalXP": "Total XP", diff --git a/lib/pangea/constants/analytics_constants.dart b/lib/pangea/constants/analytics_constants.dart index f9893d638..2d295305c 100644 --- a/lib/pangea/constants/analytics_constants.dart +++ b/lib/pangea/constants/analytics_constants.dart @@ -2,4 +2,9 @@ class AnalyticsConstants { static const int xpPerLevel = 500; static const int vocabUseMaxXP = 30; static const int morphUseMaxXP = 500; + static const int xpForGreens = 30; + static const int xpForFlower = 100; + static const String emojiForSeed = "🫛"; + static const String emojiForGreen = "🌱"; + static const String emojiForFlower = "🌸"; } diff --git a/lib/pangea/enum/lemma_category_enum.dart b/lib/pangea/enum/lemma_category_enum.dart new file mode 100644 index 000000000..1cc5e0182 --- /dev/null +++ b/lib/pangea/enum/lemma_category_enum.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/constants/analytics_constants.dart'; + +enum LemmaCategoryEnum { + flowers, + greens, + seeds, +} + +extension LemmaCategoryExtension on LemmaCategoryEnum { + Color get color { + switch (this) { + case LemmaCategoryEnum.flowers: + return Color.lerp(AppConfig.primaryColor, Colors.white, 0.6) ?? + AppConfig.primaryColor; + case LemmaCategoryEnum.greens: + return Color.lerp(AppConfig.success, Colors.white, 0.6) ?? + AppConfig.success; + case LemmaCategoryEnum.seeds: + return Color.lerp(AppConfig.gold, Colors.white, 0.6) ?? AppConfig.gold; + } + } + + Color get darkColor { + switch (this) { + case LemmaCategoryEnum.flowers: + return Color.lerp(AppConfig.primaryColor, Colors.white, 0.3) ?? + AppConfig.primaryColor; + case LemmaCategoryEnum.greens: + return Color.lerp(AppConfig.success, Colors.black, 0.3) ?? + AppConfig.success; + case LemmaCategoryEnum.seeds: + return Color.lerp(AppConfig.gold, Colors.black, 0.3) ?? AppConfig.gold; + } + } + + String get emoji { + switch (this) { + case LemmaCategoryEnum.flowers: + return AnalyticsConstants.emojiForFlower; + case LemmaCategoryEnum.greens: + return AnalyticsConstants.emojiForGreen; + case LemmaCategoryEnum.seeds: + return AnalyticsConstants.emojiForSeed; + } + } + + String get xpString { + switch (this) { + case LemmaCategoryEnum.flowers: + return ">${AnalyticsConstants.xpForFlower}"; + case LemmaCategoryEnum.greens: + return ">${AnalyticsConstants.xpForGreens}"; + case LemmaCategoryEnum.seeds: + return "<${AnalyticsConstants.xpForGreens}"; + } + } +} diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index 78413a17c..18287c640 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/constants/analytics_constants.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; @@ -28,7 +29,7 @@ class ConstructListModel { List get truncatedUses => _uses.take(100).toList(); /// A map of lemmas to ConstructUses, each of which contains a lemma - /// key = lemmma + constructType.string, value = ConstructUses + /// key = lemma + constructType.string, value = ConstructUses Map _constructMap = {}; /// Storing this to avoid re-running the sort operation each time this needs to @@ -229,6 +230,26 @@ class ConstructListModel { ); } + // uses where points < AnalyticConstants.xpForGreens + List get seeds => _constructList + .where( + (use) => use.points < AnalyticsConstants.xpForGreens, + ) + .toList(); + + List get greens => _constructList + .where( + (use) => + use.points >= AnalyticsConstants.xpForGreens && + use.points < AnalyticsConstants.xpForFlower, + ) + .toList(); + + List get flowers => _constructList + .where( + (use) => use.points >= AnalyticsConstants.xpForFlower, + ) + .toList(); // Not storing this for now to reduce memory load // It's only used by downloads, so doesn't need to be accessible on the fly Map> lemmasToUses({ diff --git a/lib/pangea/models/analytics/construct_use_model.dart b/lib/pangea/models/analytics/construct_use_model.dart index 46319f85d..b7d9a96b9 100644 --- a/lib/pangea/models/analytics/construct_use_model.dart +++ b/lib/pangea/models/analytics/construct_use_model.dart @@ -1,3 +1,4 @@ +import 'package:fluffychat/pangea/constants/analytics_constants.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; @@ -64,4 +65,19 @@ class ConstructUses { }; return json; } + + String get xpEmoji { + if (points < AnalyticsConstants.xpForGreens) { + // bean emoji + return AnalyticsConstants.emojiForSeed; + } + + if (points < AnalyticsConstants.xpForFlower) { + // sprout emoji + return AnalyticsConstants.emojiForGreen; + } + + // flower emoji + return AnalyticsConstants.emojiForFlower; + } } diff --git a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart index da8859dbd..696a9df14 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/learning_progress_indicators.dart @@ -9,12 +9,13 @@ import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; import 'package:fluffychat/pangea/pages/settings_learning/settings_learning.dart'; -import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/learning_progress_bar.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/learning_settings_button.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/level_badge.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/level_bar_popup.dart'; +import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/morph_analytics_popup/morph_analytics_popup.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart'; +import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/vocab_analytics_popup.dart'; import 'package:fluffychat/widgets/matrix.dart'; /// A summary of "My Analytics" shown at the top of the chat list @@ -109,27 +110,33 @@ class LearningProgressIndicatorsState l2: userL2?.getDisplayName(context) ?? userL2?.langCode, ), Row( - children: ProgressIndicatorEnum.values - .where((i) => i != ProgressIndicatorEnum.level) - .map( - (indicator) => Padding( - padding: const EdgeInsets.only(left: 6), - child: ProgressIndicatorBadge( - points: uniqueLemmas(indicator), - loading: _loading, - onTap: () { - showDialog( - context: context, - builder: (c) => AnalyticsPopup( - type: indicator.constructType, - ), - ); - }, - indicator: indicator, + children: [ + ProgressIndicatorBadge( + points: uniqueLemmas(ProgressIndicatorEnum.wordsUsed), + loading: _loading, + onTap: () { + showDialog( + context: context, + builder: (c) => const VocabAnalyticsPopup(), + ); + }, + indicator: ProgressIndicatorEnum.wordsUsed, + ), + ProgressIndicatorBadge( + points: uniqueLemmas(ProgressIndicatorEnum.morphsUsed), + loading: _loading, + onTap: () { + showDialog( + context: context, + builder: (c) => MorphAnalyticsPopup( + type: ProgressIndicatorEnum + .morphsUsed.constructType, ), - ), - ) - .toList(), + ); + }, + indicator: ProgressIndicatorEnum.morphsUsed, + ), + ], ), ], ), diff --git a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/morph_analytics_popup/morph_analytics_popup.dart similarity index 94% rename from lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart rename to lib/pangea/widgets/chat_list/analytics_summary/morph_analytics_popup/morph_analytics_popup.dart index 141dbe9ed..b73d6313f 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/morph_analytics_popup/morph_analytics_popup.dart @@ -6,24 +6,24 @@ import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; -import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart'; +import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/morph_analytics_popup/morph_analytics_xp_tile.dart'; import 'package:fluffychat/widgets/matrix.dart'; -class AnalyticsPopup extends StatefulWidget { +class MorphAnalyticsPopup extends StatefulWidget { final ConstructTypeEnum type; final bool showGroups; - const AnalyticsPopup({ + const MorphAnalyticsPopup({ required this.type, this.showGroups = true, super.key, }); @override - AnalyticsPopupState createState() => AnalyticsPopupState(); + MorphAnalyticsPopupState createState() => MorphAnalyticsPopupState(); } -class AnalyticsPopupState extends State { +class MorphAnalyticsPopupState extends State { String? selectedCategory; ConstructListModel get _constructsModel => MatrixState.pangeaController.getAnalytics.constructListModel; diff --git a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart b/lib/pangea/widgets/chat_list/analytics_summary/morph_analytics_popup/morph_analytics_xp_tile.dart similarity index 100% rename from lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart rename to lib/pangea/widgets/chat_list/analytics_summary/morph_analytics_popup/morph_analytics_xp_tile.dart diff --git a/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart b/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart index bcacbb733..5502f5a56 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/progress_indicator.dart @@ -20,46 +20,49 @@ class ProgressIndicatorBadge extends StatelessWidget { @override Widget build(BuildContext context) { - return Tooltip( - message: indicator.tooltip(context), - child: PressableButton( - color: Theme.of(context).colorScheme.surfaceBright, - borderRadius: BorderRadius.circular(15), - onPressed: onTap, - buttonHeight: 2.5, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(15), - color: Theme.of(context).colorScheme.surfaceBright, - ), - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - size: 14, - indicator.icon, - color: indicator.color(context), - weight: 1000, - ), - const SizedBox(width: 5), - !loading - ? Text( - points.toString(), - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: indicator.color(context), - ), - ) - : const SizedBox( - height: 8, - width: 8, - child: CircularProgressIndicator.adaptive( - strokeWidth: 2, + return Padding( + padding: const EdgeInsets.only(left: 6), + child: Tooltip( + message: indicator.tooltip(context), + child: PressableButton( + color: Theme.of(context).colorScheme.surfaceBright, + borderRadius: BorderRadius.circular(15), + onPressed: onTap, + buttonHeight: 2.5, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + color: Theme.of(context).colorScheme.surfaceBright, + ), + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + size: 14, + indicator.icon, + color: indicator.color(context), + weight: 1000, + ), + const SizedBox(width: 5), + !loading + ? Text( + points.toString(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: indicator.color(context), + ), + ) + : const SizedBox( + height: 8, + width: 8, + child: CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), ), - ), - ], + ], + ), ), ), ), diff --git a/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/analytics_xp_tile.dart b/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/analytics_xp_tile.dart new file mode 100644 index 000000000..66cb57527 --- /dev/null +++ b/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/analytics_xp_tile.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; +import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart'; + +class ConstructUsesXPTile extends StatelessWidget { + final ConstructUses constructUses; + + const ConstructUsesXPTile( + this.constructUses, { + super.key, + }); + + @override + Widget build(BuildContext context) { + final ProgressIndicatorEnum indicator = + constructUses.constructType == ConstructTypeEnum.morph + ? ProgressIndicatorEnum.morphsUsed + : ProgressIndicatorEnum.wordsUsed; + + return Tooltip( + message: + "${constructUses.points} / ${constructUses.constructType.maxXPPerLemma}", + child: ListTile( + onTap: () {}, + title: Text( + constructUses.constructType == ConstructTypeEnum.morph + ? getGrammarCopy( + category: constructUses.category, + lemma: constructUses.lemma, + context: context, + ) ?? + constructUses.lemma + : constructUses.lemma, + ), + subtitle: Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: constructUses.points / + constructUses.constructType.maxXPPerLemma, + minHeight: 20, + borderRadius: const BorderRadius.all( + Radius.circular(AppConfig.borderRadius), + ), + color: indicator.color(context), + ), + ), + const SizedBox(width: 12), + Text("${constructUses.points}xp"), + ], + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 20, + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/vocab_analytics_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/vocab_analytics_popup.dart new file mode 100644 index 000000000..f310bd9d0 --- /dev/null +++ b/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/vocab_analytics_popup.dart @@ -0,0 +1,310 @@ +import 'package:flutter/material.dart'; + +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/constants/analytics_constants.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/enum/lemma_category_enum.dart'; +import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; +import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/vocab_definition_popup.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Displays vocab analytics, sorted into categories +/// (flowers, greens, and seeds) by points +class VocabAnalyticsPopup extends StatefulWidget { + const VocabAnalyticsPopup({ + super.key, + }); + + @override + VocabAnalyticsPopupState createState() => VocabAnalyticsPopupState(); +} + +class VocabAnalyticsPopupState extends State { + ConstructListModel get _constructsModel => + MatrixState.pangeaController.getAnalytics.constructListModel; + + /// Sort entries alphabetically, to better detect duplicates + List get _sortedEntries { + final entries = + _constructsModel.constructList(type: ConstructTypeEnum.vocab); + entries + .sort((a, b) => a.lemma.toLowerCase().compareTo(b.lemma.toLowerCase())); + return entries; + } + + /// Produces list of chips with lemma content, + /// and assigns them to flowers, greens, and seeds tiles + Widget get dialogContent { + if (_constructsModel.constructList(type: ConstructTypeEnum.vocab).isEmpty) { + return Center(child: Text(L10n.of(context).noDataFound)); + } + final sortedEntries = _sortedEntries; + + // Get lists of lemmas by category + final List flowerLemmas = []; + final List greenLemmas = []; + final List seedLemmas = []; + for (int i = 0; i < sortedEntries.length; i++) { + final construct = sortedEntries[i]; + if (construct.lemma.isEmpty) { + continue; + } + final int points = construct.points; + String? displayText; + + // Check if previous or next entry has same lemma as this entry + if ((i > 0 && sortedEntries[i - 1].lemma.equals(construct.lemma)) || + ((i < sortedEntries.length - 1 && + sortedEntries[i + 1].lemma.equals(construct.lemma)))) { + final String pos = getGrammarCopy( + category: "pos", + lemma: construct.category, + context: context, + ) ?? + construct.category; + displayText = "${sortedEntries[i].lemma} (${pos.toLowerCase()})"; + } + + // Add VocabChip for lemma to relevant widget list, followed by comma + if (points < AnalyticsConstants.xpForGreens) { + seedLemmas.add( + VocabChip( + construct: construct, + displayText: displayText, + onTap: () { + showDialog( + context: context, + builder: (c) => VocabDefinitionPopup( + construct: construct, + type: LemmaCategoryEnum.seeds, + points: points, + ), + ); + }, + ), + ); + seedLemmas.add( + const Text( + ", ", + style: TextStyle( + fontSize: 15, + color: Colors.black, + ), + ), + ); + } else if (points >= AnalyticsConstants.xpForFlower) { + flowerLemmas.add( + VocabChip( + construct: construct, + displayText: displayText, + onTap: () { + showDialog( + context: context, + builder: (c) => VocabDefinitionPopup( + construct: construct, + type: LemmaCategoryEnum.flowers, + points: points, + ), + ); + }, + ), + ); + flowerLemmas.add( + const Text( + ", ", + style: TextStyle( + fontSize: 15, + color: Colors.black, + ), + ), + ); + } else { + greenLemmas.add( + VocabChip( + construct: construct, + displayText: displayText, + onTap: () { + showDialog( + context: context, + builder: (c) => VocabDefinitionPopup( + construct: construct, + type: LemmaCategoryEnum.greens, + points: points, + ), + ); + }, + ), + ); + greenLemmas.add( + const Text( + ", ", + style: TextStyle( + fontSize: 15, + color: Colors.black, + ), + ), + ); + } + } + + // Pass sorted lemmas to background tile widgets + final Widget flowers = + dialogWidget(LemmaCategoryEnum.flowers, flowerLemmas); + final Widget greens = dialogWidget(LemmaCategoryEnum.greens, greenLemmas); + final Widget seeds = dialogWidget(LemmaCategoryEnum.seeds, seedLemmas); + + return ListView( + children: [flowers, greens, seeds], + ); + } + + /// Tile that contains flowers, greens, or seeds chips + Widget dialogWidget(LemmaCategoryEnum type, List lemmaList) { + // Remove extraneous commas from lemmaList + if (lemmaList.isNotEmpty) { + lemmaList.removeLast(); + } else { + lemmaList.add( + const Text( + "No lemmas", + style: TextStyle( + fontSize: 15, + color: Colors.black, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 10, 20, 10), + child: Material( + borderRadius: + const BorderRadius.all(Radius.circular(AppConfig.borderRadius)), + color: type.color, + child: Padding( + padding: const EdgeInsets.all( + 10, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CircleAvatar( + backgroundColor: + Theme.of(context).brightness == Brightness.light + ? Colors.white + : Colors.black, + radius: 16, + child: Text( + " ${type.emoji}", + style: const TextStyle( + fontSize: 14, + ), + ), + ), + Text( + " ${type.xpString} XP", + style: const TextStyle( + fontSize: 15, + color: Colors.black, + ), + ), + ], + ), + const SizedBox( + height: 5, + ), + Wrap( + spacing: 0, + runSpacing: 0, + children: lemmaList, + ), + const SizedBox( + height: 5, + ), + ], + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 400, + maxHeight: 600, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20.0), + child: Scaffold( + appBar: AppBar( + title: Text(ProgressIndicatorEnum.wordsUsed.tooltip(context)), + leading: IconButton( + icon: const Icon(Icons.close), + onPressed: Navigator.of(context).pop, + ), + // TODO: add search and training buttons? + ), + body: Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: dialogContent, + ), + ), + ), + ), + ); + } +} + +/// A simple chip with the text of the lemma +// TODO: highlights on hover +// callback on click +// has some padding to separate from other chips +// otherwise, is very visually simple with transparent border/background/etc +class VocabChip extends StatelessWidget { + final ConstructUses construct; + final String? displayText; + final VoidCallback onTap; + + const VocabChip({ + super.key, + required this.construct, + required this.onTap, + this.displayText, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Text( + displayText ?? construct.lemma, + style: const TextStyle( + // Workaround to add space between text and underline + color: Colors.transparent, + shadows: [ + Shadow( + color: Colors.black, + offset: Offset(0, -3), + ), + ], + decoration: TextDecoration.underline, + decorationStyle: TextDecorationStyle.dashed, + decorationColor: Colors.black, + decorationThickness: 1, + fontSize: 15, + ), + ), + ); + } +} diff --git a/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/vocab_definition_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/vocab_definition_popup.dart new file mode 100644 index 000000000..28893d24b --- /dev/null +++ b/lib/pangea/widgets/chat_list/analytics_summary/vocab_analytics_popup/vocab_definition_popup.dart @@ -0,0 +1,583 @@ +import 'package:flutter/material.dart'; + +import 'package:collection/collection.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:matrix/matrix.dart'; + +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/config/themes.dart'; +import 'package:fluffychat/pangea/constants/language_constants.dart'; +import 'package:fluffychat/pangea/constants/morph_categories_and_labels.dart'; +import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/enum/lemma_category_enum.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/models/lemma.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_text_model.dart'; +import 'package:fluffychat/pangea/repo/lemma_info/lemma_info_repo.dart'; +import 'package:fluffychat/pangea/repo/lemma_info/lemma_info_request.dart'; +import 'package:fluffychat/pangea/repo/lemma_info/lemma_info_response.dart'; +import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/word_audio_button.dart'; +import 'package:fluffychat/utils/platform_infos.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +/// Displays information about selected lemma, and its usage +class VocabDefinitionPopup extends StatefulWidget { + final ConstructUses construct; + final LemmaCategoryEnum type; + final int points; + + const VocabDefinitionPopup({ + super.key, + required this.construct, + required this.type, + required this.points, + }); + + @override + VocabDefinitionPopupState createState() => VocabDefinitionPopupState(); +} + +class VocabDefinitionPopupState extends State { + String? exampleEventID; + LemmaInfoResponse? res; + late Future definition; + String? emoji; + PangeaToken? token; + String? morphFeature; + // Lists of lemma uses for the given exercise types; true if positive XP + List writingUses = []; + List hearingUses = []; + List readingUses = []; + late Future> writingExamples; + Set? forms; + String? formString; + + @override + void initState() { + definition = getDefinition(); + writingExamples = getExamples(loadUses()); + + // Get possible forms of lemma + final ConstructListModel constructsModel = + MatrixState.pangeaController.getAnalytics.constructListModel; + forms = (constructsModel.lemmasToUses())[widget.construct.lemma] + ?.first + .uses + .map((e) => e.form) + .whereType() + .toSet(); + + // Save forms as string + if (forms != null) { + formString = " "; + for (final String form in forms!) { + if (form.isNotEmpty) { + formString = "${formString!}$form, "; + } + } + if (formString!.length <= 2) { + formString = null; + } else { + formString = formString!.substring(0, formString!.length - 2); + } + } + + // Find selected emoji, if applicable, using PangeaToken.getEmoji + emoji = PangeaToken( + text: PangeaTokenText( + offset: 0, + content: widget.construct.lemma, + length: widget.construct.lemma.length, + ), + lemma: Lemma( + text: widget.construct.lemma, + saveVocab: false, + form: widget.construct.lemma, + ), + pos: widget.construct.category, + morph: {}, + ).getEmoji(); + + exampleEventID = widget.construct.uses + .firstWhereOrNull((e) => e.metadata.eventId != null) + ?.metadata + .eventId; + super.initState(); + } + + /// Sort uses of lemma associated with writing, reading, and listening. + List loadUses() { + final List writingUsesDetailed = []; + for (final OneConstructUse use in widget.construct.uses) { + if (use.useType.pointValue == 0) { + continue; + } + final bool positive = use.useType.pointValue > 0; + final ConstructUseTypeEnum activityType = use.useType; + switch (activityType) { + case ConstructUseTypeEnum.wa: + case ConstructUseTypeEnum.ga: + case ConstructUseTypeEnum.unk: + case ConstructUseTypeEnum.corIt: + case ConstructUseTypeEnum.ignIt: + case ConstructUseTypeEnum.incIt: + case ConstructUseTypeEnum.corIGC: + case ConstructUseTypeEnum.ignIGC: + case ConstructUseTypeEnum.incIGC: + case ConstructUseTypeEnum.corL: + case ConstructUseTypeEnum.ignL: + case ConstructUseTypeEnum.incL: + case ConstructUseTypeEnum.corM: + case ConstructUseTypeEnum.ignM: + case ConstructUseTypeEnum.incM: + writingUses.add(positive); + writingUsesDetailed.add(use); + break; + case ConstructUseTypeEnum.corWL: + case ConstructUseTypeEnum.ignWL: + case ConstructUseTypeEnum.incWL: + case ConstructUseTypeEnum.corHWL: + case ConstructUseTypeEnum.ignHWL: + case ConstructUseTypeEnum.incHWL: + hearingUses.add(positive); + break; + case ConstructUseTypeEnum.corPA: + case ConstructUseTypeEnum.ignPA: + case ConstructUseTypeEnum.incPA: + readingUses.add(positive); + break; + default: + break; + } + } + // Save writing uses to find usage examples + return writingUsesDetailed; + } + + /// Returns a wrapping row of dots - green if positive usage, red if negative + Widget getUsageDots(List uses) { + final List dots = []; + for (final bool use in uses) { + dots.add( + Container( + width: 15.0, + height: 15.0, + decoration: BoxDecoration( + color: use ? AppConfig.success : Colors.red, + shape: BoxShape.circle, + ), + ), + ); + } + // Clips content (and enables scrolling) if there are 5 or more rows of dots + return ConstrainedBox( + constraints: BoxConstraints( + // TODO: May need different maxWidth for android devices + maxWidth: PlatformInfos.isMobile ? 250 : 350, + maxHeight: 90, + ), + child: SingleChildScrollView( + child: Wrap( + spacing: 3, + runSpacing: 5, + children: dots, + ), + ), + ); + } + + /// Get examples of messages that uses this lemma + Future> getExamples( + List writingUsesDetailed, + ) async { + final Set exampleText = {}; + final List examples = []; + for (final OneConstructUse use in writingUsesDetailed) { + if (use.metadata.eventId == null) { + continue; + } + final Room? room = MatrixState.pangeaController.matrixState.client + .getRoomById(use.metadata.roomId); + final Event? event = await room?.getEventById(use.metadata.eventId!); + final String? messageText = event?.text; + + if (messageText != null) { + // Save text to set, to avoid duplicate entries + exampleText.add(messageText); + if (exampleText.length >= 3) { + break; + } + } + } + + // Turn message text into widgets: + for (final String text in exampleText) { + examples.add( + const SizedBox( + height: 5, + ), + ); + examples.add( + Container( + decoration: BoxDecoration( + color: widget.type.color, + borderRadius: BorderRadius.circular( + 4, + ), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 1.5, + ), + child: Text( + text, + style: const TextStyle( + color: Colors.black, + ), + ), + ), + ); + } + return examples; + } + + /// Fetch the meaning of the lemma + Future getDefinition() async { + final lang2 = + MatrixState.pangeaController.languageController.userL2?.langCode; + if (lang2 == null) { + debugPrint("No lang2, cannot retrieve definition"); + return L10n.of(context).meaningNotFound; + } + + final LemmaInfoRequest lemmaDefReq = LemmaInfoRequest( + partOfSpeech: widget.construct.category, + lemmaLang: lang2, + userL1: + MatrixState.pangeaController.languageController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + lemma: widget.construct.lemma, + ); + res = await LemmaInfoRepo.get(lemmaDefReq); + return res?.meaning; + } + + @override + Widget build(BuildContext context) { + final Color textColor = Theme.of(context).brightness != Brightness.light + ? widget.type.color + : widget.type.darkColor; + + return Dialog( + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 400, + maxHeight: 600, + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(20.0), + child: Scaffold( + appBar: AppBar( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (emoji != null) + Text( + emoji!, + ), + if (emoji == null) + Tooltip( + message: L10n.of(context).noEmojiSelectedTooltip, + child: Icon( + Icons.add_reaction_outlined, + size: 25, + color: textColor.withValues(alpha: 0.7), + ), + ), + const SizedBox( + width: 7, + ), + Text( + widget.construct.lemma, + style: TextStyle( + color: textColor, + ), + ), + ], + ), + centerTitle: true, + leading: IconButton( + icon: Icon(Icons.adaptive.arrow_back_outlined), + color: textColor, + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + onPressed: Navigator.of(context).pop, + ), + actions: (exampleEventID != null) + ? [ + Column( + children: [ + const SizedBox(height: 6), + WordAudioButton( + text: widget.construct.lemma, + ttsController: TtsController(), + eventID: exampleEventID!, + ), + ], + ), + const SizedBox(width: 8), + ] + : [], + ), + body: SingleChildScrollView( + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 20, horizontal: 10), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Tooltip( + message: L10n.of(context).grammarCopyPOS, + child: Icon( + (morphFeature != null) + ? getIconForMorphFeature(morphFeature!) + : Symbols.toys_and_games, + size: 23, + color: textColor.withValues(alpha: 0.7), + ), + ), + const SizedBox( + width: 5, + ), + Text( + getGrammarCopy( + category: "pos", + lemma: widget.construct.category, + context: context, + ) ?? + widget.construct.category, + style: TextStyle( + color: textColor, + fontSize: 16, + ), + ), + ], + ), + const SizedBox( + height: 20, + ), + + Align( + alignment: Alignment.topLeft, + child: FutureBuilder( + future: definition, + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + if (snapshot.hasData) { + return RichText( + text: TextSpan( + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurface, + fontSize: 16, + ), + children: [ + TextSpan( + text: L10n.of(context).meaningSectionHeader, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan(text: " ${snapshot.data!}"), + ], + ), + ); + } else { + return Wrap( + children: [ + Text( + L10n.of(context).meaningSectionHeader, + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurface, + fontSize: 16, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox( + width: 10, + ), + const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ], + ); + } + }, + ), + ), + + const SizedBox( + height: 10, + ), + + Align( + alignment: Alignment.topLeft, + child: RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: 16, + ), + children: [ + TextSpan( + text: L10n.of(context).formSectionHeader, + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: formString ?? + " ${L10n.of(context).formsNotFound}", + ), + ], + ), + ), + ), + + const SizedBox( + height: 20, + ), + Divider( + height: 3, + color: textColor.withValues(alpha: 0.7), + ), + const SizedBox( + height: 20, + ), + Text( + "${widget.type.emoji} ${widget.points} XP", + style: TextStyle( + color: textColor, + fontSize: 20, + ), + ), + const SizedBox( + height: 20, + ), + // Writing exercise section + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Tooltip( + message: L10n.of(context).writingExercisesTooltip, + child: Icon( + Symbols.edit_square, + size: 25, + color: textColor.withValues(alpha: 0.7), + ), + ), + const SizedBox( + width: 7, + ), + getUsageDots(writingUses), + ], + ), + + FutureBuilder( + future: writingExamples, + builder: ( + BuildContext context, + AsyncSnapshot> snapshot, + ) { + if (snapshot.hasData) { + return Align( + alignment: Alignment.topLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: snapshot.data!, + ), + ); + } else { + return const Column( + children: [ + SizedBox(height: 10), + CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ], + ); + } + }, + ), + + const SizedBox( + height: 20, + ), + // Listening exercise section + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Tooltip( + message: L10n.of(context).listeningExercisesTooltip, + child: Icon( + Icons.hearing, + size: 25, + color: textColor.withValues(alpha: 0.7), + ), + ), + const SizedBox( + width: 7, + ), + getUsageDots(hearingUses), + ], + ), + const SizedBox( + height: 20, + ), + // Reading exercise section + Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Tooltip( + message: L10n.of(context).readingExercisesTooltip, + child: Icon( + Symbols.two_pager, + size: 25, + color: textColor.withValues(alpha: 0.7), + ), + ), + const SizedBox( + width: 7, + ), + getUsageDots(readingUses), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +}