From e2b991b36b0b224e4fbbbbac494f87ce4e9e1ac9 Mon Sep 17 00:00:00 2001 From: Kelrap <99418823+Kelrap@users.noreply.github.com> Date: Mon, 27 Jan 2025 14:01:42 -0500 Subject: [PATCH] Vocab v2 edits (#1525) * Emoji as getter, add LearningSkillsEnum * Remove hard-coding for font styles * Remove excess state saving * Remove type+point parameters from definition popup * Fix emoji null check notation * Edit dot widget size for android * Further reduce state saving in definition popup * Removed more hardcoding * fix: UI updates to vocab analytics popup --------- Co-authored-by: ggurdin --- assets/l10n/intl_en.arb | 9 +- .../enums/construct_use_type_enum.dart | 36 + .../analytics/enums/learning_skills_enum.dart | 7 + .../models/construct_list_model.dart | 6 + .../analytics/models/construct_use_model.dart | 12 + .../vocab_analytics_popup.dart | 387 ++++---- .../vocab_definition_popup.dart | 913 ++++++++---------- .../practice_activity/word_audio_button.dart | 14 +- 8 files changed, 664 insertions(+), 720 deletions(-) create mode 100644 lib/pangea/analytics/enums/learning_skills_enum.dart diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 777846143..ba58fd2ab 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4664,9 +4664,9 @@ "meaningSectionHeader": "Meaning:", "formSectionHeader": "Forms used in chats:", "noEmojiSelectedTooltip": "No emoji selected", - "writingExercisesTooltip": "Writing exercises", - "listeningExercisesTooltip": "Listening exercises", - "readingExercisesTooltip": "Reading exercises", + "writingExercisesTooltip": "Writing activities", + "listeningExercisesTooltip": "Listening activities", + "readingExercisesTooltip": "Reading activities", "meaningNotFound": "Meaning could not be found.", "formsNotFound": "Forms could not be found.", "chooseBestDefinition": "What does this word mean?", @@ -4770,5 +4770,6 @@ "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" + "noBookmarkedActivities": "No bookmarked activities", + "noLemmasFound": "No lemmas found" } \ No newline at end of file diff --git a/lib/pangea/analytics/enums/construct_use_type_enum.dart b/lib/pangea/analytics/enums/construct_use_type_enum.dart index 0a7ee7275..6d0c78746 100644 --- a/lib/pangea/analytics/enums/construct_use_type_enum.dart +++ b/lib/pangea/analytics/enums/construct_use_type_enum.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:fluffychat/pangea/analytics/enums/analytics_summary_enum.dart'; +import 'package:fluffychat/pangea/analytics/enums/learning_skills_enum.dart'; import 'package:fluffychat/pangea/toolbar/enums/activity_type_enum.dart'; enum ConstructUseTypeEnum { @@ -247,6 +248,41 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { } } + /// Categorize construct use types as writing, reading, speaking, hearing, and other + LearningSkillsEnum get skillsEnumType { + switch (this) { + 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: + return LearningSkillsEnum.writing; + case ConstructUseTypeEnum.corWL: + case ConstructUseTypeEnum.ignWL: + case ConstructUseTypeEnum.incWL: + case ConstructUseTypeEnum.corHWL: + case ConstructUseTypeEnum.ignHWL: + case ConstructUseTypeEnum.incHWL: + return LearningSkillsEnum.hearing; + case ConstructUseTypeEnum.corPA: + case ConstructUseTypeEnum.ignPA: + case ConstructUseTypeEnum.incPA: + return LearningSkillsEnum.reading; + default: + return LearningSkillsEnum.other; + } + } + AnalyticsSummaryEnum? get summaryEnumType { switch (this) { case ConstructUseTypeEnum.wa: diff --git a/lib/pangea/analytics/enums/learning_skills_enum.dart b/lib/pangea/analytics/enums/learning_skills_enum.dart new file mode 100644 index 000000000..bf9c62042 --- /dev/null +++ b/lib/pangea/analytics/enums/learning_skills_enum.dart @@ -0,0 +1,7 @@ +enum LearningSkillsEnum { + writing, + reading, + speaking, + hearing, + other, +} diff --git a/lib/pangea/analytics/models/construct_list_model.dart b/lib/pangea/analytics/models/construct_list_model.dart index 3f6ffe125..3d81f8cfd 100644 --- a/lib/pangea/analytics/models/construct_list_model.dart +++ b/lib/pangea/analytics/models/construct_list_model.dart @@ -211,6 +211,12 @@ class ConstructListModel { } } + List getConstructUsesByLemma(String lemma) { + return _constructList.where((constructUse) { + return constructUse.lemma == lemma; + }).toList(); + } + List constructList({ConstructTypeEnum? type}) => _constructList .where( (constructUse) => type == null || constructUse.constructType == type, diff --git a/lib/pangea/analytics/models/construct_use_model.dart b/lib/pangea/analytics/models/construct_use_model.dart index 0263351fd..bb5b64feb 100644 --- a/lib/pangea/analytics/models/construct_use_model.dart +++ b/lib/pangea/analytics/models/construct_use_model.dart @@ -1,5 +1,7 @@ +import 'package:fluffychat/pangea/analytics/constants/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics/enums/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics/enums/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics/enums/lemma_category_enum.dart'; import 'package:fluffychat/pangea/analytics/models/constructs_model.dart'; import 'package:fluffychat/pangea/toolbar/models/practice_activity_model.dart'; @@ -64,4 +66,14 @@ class ConstructUses { }; return json; } + + /// Get the lemma category, based on points + LemmaCategoryEnum get lemmaCategory { + if (points < AnalyticsConstants.xpForGreens) { + return LemmaCategoryEnum.seeds; + } else if (points >= AnalyticsConstants.xpForFlower) { + return LemmaCategoryEnum.flowers; + } + return LemmaCategoryEnum.greens; + } } diff --git a/lib/pangea/analytics/widgets/analytics_summary/vocab_analytics_popup/vocab_analytics_popup.dart b/lib/pangea/analytics/widgets/analytics_summary/vocab_analytics_popup/vocab_analytics_popup.dart index a819a3a59..d6de2bcd4 100644 --- a/lib/pangea/analytics/widgets/analytics_summary/vocab_analytics_popup/vocab_analytics_popup.dart +++ b/lib/pangea/analytics/widgets/analytics_summary/vocab_analytics_popup/vocab_analytics_popup.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:collection/collection.dart'; import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix.dart'; @@ -40,126 +41,208 @@ class VocabAnalyticsPopupState extends State { 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) { + ConstructUses? _selectedConstruct; + + void _setSelectedConstruct(ConstructUses? construct) { + if (mounted) { + setState(() { + _selectedConstruct = construct; + }); + } + } + + @override + Widget build(BuildContext context) { + return FullWidthDialog( + dialogContent: _selectedConstruct == null + ? LemmaListDialogContent( + lemmas: _sortedEntries, + onTap: _setSelectedConstruct, + ) + : VocabDefinitionPopup( + construct: _selectedConstruct!, + onClose: () => _setSelectedConstruct(null), + ), + maxWidth: 600, + maxHeight: 800, + ); + } +} + +class VocabChip { + final ConstructUses construct; + final String? displayText; + + VocabChip({ + required this.construct, + this.displayText, + }); +} + +class LemmaListSection extends StatelessWidget { + final LemmaCategoryEnum type; + final List lemmas; + final Function(ConstructUses) onTap; + + const LemmaListSection({ + super.key, + required this.type, + required this.lemmas, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(10), + margin: const EdgeInsets.symmetric(vertical: 6.0, horizontal: 16.0), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(AppConfig.borderRadius), + ), + color: type.color, + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + CustomizedSvg( + svgUrl: type.svgURL, + colorReplacements: const {}, + errorIcon: Text(type.emoji), + ), + Text( + " ${type.xpString} XP", + style: TextStyle( + fontSize: Theme.of(context).textTheme.bodyLarge?.fontSize, + color: Theme.of(context).colorScheme.onPrimaryFixed, + ), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: lemmas.isEmpty + ? Text( + L10n.of(context).noLemmasFound, + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + ), + ) + : Wrap( + spacing: 0, + runSpacing: 0, + children: lemmas.mapIndexed((index, lemma) { + return MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => onTap(lemma.construct), + child: Text( + "${lemma.displayText ?? lemma.construct.lemma}${index < lemmas.length - 1 ? ', ' : ''}", + style: TextStyle( + color: Colors.transparent, + shadows: [ + Shadow( + color: Theme.of(context) + .colorScheme + .onPrimaryFixed, + offset: const Offset(0, -3), + ), + ], + decoration: TextDecoration.underline, + decorationStyle: TextDecorationStyle.dashed, + decorationColor: + Theme.of(context).colorScheme.onPrimaryFixed, + decorationThickness: 1, + ), + ), + ), + ); + }).toList(), + ), + ), + ], + ), + ); + } +} + +class LemmaListDialogContent extends StatelessWidget { + final List lemmas; + final Function(ConstructUses) onTap; + + const LemmaListDialogContent({ + super.key, + required this.lemmas, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + if (lemmas.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]; + final List flowerLemmas = []; + final List greenLemmas = []; + final List seedLemmas = []; + + for (int i = 0; i < lemmas.length; i++) { + final construct = lemmas[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)))) { + if ((i > 0 && lemmas[i - 1].lemma.equals(construct.lemma)) || + ((i < lemmas.length - 1 && + lemmas[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()})"; + displayText = "${lemmas[i].lemma} (${pos.toLowerCase()})"; } - // Add VocabChip for lemma to relevant widget list, followed by comma + final lemma = VocabChip( + construct: construct, + displayText: displayText, + ); + 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, - ), - ), - ); + seedLemmas.add(lemma); } 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, - ), - ), - ); + flowerLemmas.add(lemma); } 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, - ), - ), - ); + greenLemmas.add(lemma); } } // 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); + final Widget flowers = LemmaListSection( + type: LemmaCategoryEnum.flowers, + lemmas: flowerLemmas, + onTap: onTap, + ); + + final Widget greens = LemmaListSection( + type: LemmaCategoryEnum.greens, + lemmas: greenLemmas, + onTap: onTap, + ); + + final Widget seeds = LemmaListSection( + type: LemmaCategoryEnum.seeds, + lemmas: seedLemmas, + onTap: onTap, + ); return Scaffold( appBar: AppBar( @@ -178,120 +261,4 @@ class VocabAnalyticsPopupState extends State { ), ); } - - /// 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: [ - CustomizedSvg( - svgUrl: type.svgURL, - colorReplacements: const {}, - errorIcon: Text(type.emoji), - ), - 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 FullWidthDialog( - dialogContent: dialogContent, - maxWidth: 600, - maxHeight: 800, - ); - } -} - -/// 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/analytics/widgets/analytics_summary/vocab_analytics_popup/vocab_definition_popup.dart b/lib/pangea/analytics/widgets/analytics_summary/vocab_analytics_popup/vocab_definition_popup.dart index 06913eaf3..bf271abdd 100644 --- a/lib/pangea/analytics/widgets/analytics_summary/vocab_analytics_popup/vocab_definition_popup.dart +++ b/lib/pangea/analytics/widgets/analytics_summary/vocab_analytics_popup/vocab_definition_popup.dart @@ -1,16 +1,14 @@ 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/analytics/constants/morph_categories_and_labels.dart'; import 'package:fluffychat/pangea/analytics/enums/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/analytics/enums/learning_skills_enum.dart'; import 'package:fluffychat/pangea/analytics/enums/lemma_category_enum.dart'; -import 'package:fluffychat/pangea/analytics/models/construct_list_model.dart'; import 'package:fluffychat/pangea/analytics/models/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics/models/constructs_model.dart'; import 'package:fluffychat/pangea/analytics/models/lemma.dart'; @@ -24,235 +22,56 @@ 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/toolbar/controllers/tts_controller.dart'; import 'package:fluffychat/pangea/toolbar/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 { +class VocabDefinitionPopup extends StatelessWidget { final ConstructUses construct; - final LemmaCategoryEnum type; - final int points; + final VoidCallback onClose; const VocabDefinitionPopup({ super.key, required this.construct, - required this.type, - required this.points, + required this.onClose, }); - @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, - ), + String? get emoji => PangeaToken( + text: PangeaTokenText( + offset: 0, + content: construct.lemma, + length: construct.lemma.length, ), - ); - } - // 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, + lemma: Lemma( + text: construct.lemma, + saveVocab: false, + form: construct.lemma, ), - ), - ); - } + pos: construct.category, + morph: {}, + ).getEmoji(); - /// 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; + /// 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); - if (messageText != null) { - // Save text to set, to avoid duplicate entries - exampleText.add(messageText); - if (exampleText.length >= 3) { - break; - } - } - } + final forms = constructs + .map((e) => e.uses) + .expand((element) => element) + .where((use) => use.useType.pointValue > 0) + .map((e) => e.form?.toLowerCase()) + .toSet() + .whereType() + .toList(); - // 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; + if (forms.isEmpty) return null; + return forms.join(", "); } /// Fetch the meaning of the lemma - Future getDefinition() async { + Future getDefinition(BuildContext context) async { final lang2 = MatrixState.pangeaController.languageController.userL2?.langCode; if (lang2 == null) { @@ -261,342 +80,442 @@ class VocabDefinitionPopupState extends State { } final LemmaInfoRequest lemmaDefReq = LemmaInfoRequest( - partOfSpeech: widget.construct.category, + partOfSpeech: construct.category, lemmaLang: lang2, userL1: MatrixState.pangeaController.languageController.userL1?.langCode ?? LanguageKeys.defaultLanguage, - lemma: widget.construct.lemma, + lemma: construct.lemma, ); - res = await LemmaInfoRepo.get(lemmaDefReq); - return res?.meaning; + final LemmaInfoResponse 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; + ? construct.lemmaCategory.color + : construct.lemmaCategory.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( + return Scaffold( + appBar: AppBar( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 42, + child: emoji == null + ? Tooltip( message: L10n.of(context).noEmojiSelectedTooltip, child: Icon( Icons.add_reaction_outlined, - size: 25, + size: 24, color: textColor.withValues(alpha: 0.7), ), + ) + : Text(emoji!), + ), + const SizedBox(width: 10.0), + Text( + construct.lemma, + style: TextStyle( + color: textColor, + ), + ), + const SizedBox(width: 10.0), + WordAudioButton( + text: construct.lemma, + ttsController: TtsController(), + size: 24, + ), + const SizedBox(width: 24), + ], + ), + centerTitle: true, + leading: SizedBox( + width: 24, + child: BackButton(onPressed: onClose), + ), + ), + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 20), + child: Column( + children: [ + 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), ), - const SizedBox( - width: 7, ), + const SizedBox(width: 10.0), Text( - widget.construct.lemma, + getGrammarCopy( + category: "pos", + lemma: construct.category, + context: context, + ) ?? + construct.category, style: TextStyle( + fontSize: Theme.of(context).textTheme.bodyLarge?.fontSize, 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, + const SizedBox(height: 20.0), + Padding( + padding: const EdgeInsets.all(5.0), + child: Align( + alignment: Alignment.topLeft, + child: FutureBuilder( + future: getDefinition(context), + builder: ( + BuildContext context, + AsyncSnapshot snapshot, + ) { + if (snapshot.hasData) { + return RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + ), + children: [ + TextSpan( + text: L10n.of(context).meaningSectionHeader, + style: const TextStyle( + fontWeight: FontWeight.bold, ), - 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, + TextSpan(text: " ${snapshot.data!}"), + ], ), - children: [ - TextSpan( - text: L10n.of(context).formSectionHeader, - style: const TextStyle( + ); + } else { + return Wrap( + children: [ + Text( + L10n.of(context).meaningSectionHeader, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, fontWeight: FontWeight.bold, ), ), - TextSpan( - text: formString ?? - " ${L10n.of(context).formsNotFound}", + const SizedBox( + width: 10, + ), + const CircularProgressIndicator.adaptive( + strokeWidth: 2, ), ], - ), + ); + } + }, + ), + ), + ), + Padding( + padding: const EdgeInsets.all(5.0), + child: Align( + alignment: Alignment.topLeft, + child: RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface, ), - ), - - const SizedBox( - height: 20, - ), - Divider( - height: 3, - color: textColor.withValues(alpha: 0.7), - ), - const SizedBox( - height: 20, - ), - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Padding( - padding: const EdgeInsets.only(right: 10), - child: CustomizedSvg( - svgUrl: widget.type.svgURL, - colorReplacements: const {}, - errorIcon: Text( - widget.type.emoji, - style: const TextStyle( - fontSize: 20, - ), - ), + children: [ + TextSpan( + text: L10n.of(context).formSectionHeader, + style: const TextStyle( + fontWeight: FontWeight.bold, ), ), - Text( - "${widget.points} XP", - style: TextStyle( - color: textColor, - fontSize: 20, - ), + TextSpan( + text: + " ${formString ?? L10n.of(context).formsNotFound}", ), ], ), - 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, + ), + ), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Divider( + height: 3, + color: textColor.withValues(alpha: 0.7), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(right: 10), + child: CustomizedSvg( + svgUrl: construct.lemmaCategory.svgURL, + colorReplacements: const {}, + errorIcon: Text( + construct.lemmaCategory.emoji, + style: const TextStyle( + fontSize: 20, ), - 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, - ), - ], - ); - } - }, + ), + Text( + "${construct.points} XP", + style: TextStyle( + color: textColor, + fontSize: Theme.of(context).textTheme.bodyLarge?.fontSize, ), + ), + ], + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: LemmaUseExampleMessages(construct: construct), + ), + // Writing exercise section + LemmaUsageDots( + construct: construct, + category: LearningSkillsEnum.writing, + tooltip: L10n.of(context).writingExercisesTooltip, + icon: Symbols.edit_square, + ), + // Listening exercise section + LemmaUsageDots( + construct: construct, + category: LearningSkillsEnum.hearing, + tooltip: L10n.of(context).listeningExercisesTooltip, + icon: Symbols.hearing, + ), + // Reading exercise section + LemmaUsageDots( + construct: construct, + category: LearningSkillsEnum.reading, + tooltip: L10n.of(context).readingExercisesTooltip, + icon: Symbols.two_pager, + ), + ], + ), + ), + ), + ); + } +} - 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, +class LemmaUseExampleMessages extends StatelessWidget { + final ConstructUses construct; + + const LemmaUseExampleMessages({ + super.key, + required this.construct, + }); + + Future> _getExampleMessages() async { + final Set examples = {}; + for (final OneConstructUse use in construct.uses) { + if (use.useType.skillsEnumType != LearningSkillsEnum.writing || + use.metadata.eventId == null || + use.form == null || + use.pointValue <= 0) { + 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 && messageText.contains(use.form!)) { + final int offset = messageText.indexOf(use.form!); + examples.add( + ExampleMessage( + message: messageText, + offset: offset, + length: use.form!.length, + ), + ); + if (examples.length > 4) break; + } + } + + return examples.toList(); + } + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _getExampleMessages(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Align( + alignment: Alignment.topLeft, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: snapshot.data!.map((e) { + return Container( + decoration: BoxDecoration( + color: construct.lemmaCategory.color, + borderRadius: BorderRadius.circular(4), + ), + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + margin: const EdgeInsets.only(bottom: 8), + constraints: const BoxConstraints( + maxWidth: FluffyThemes.columnWidth * 1.5, + ), + child: RichText( + text: TextSpan( + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryFixed, + ), children: [ - Tooltip( - message: L10n.of(context).readingExercisesTooltip, - child: Icon( - Symbols.two_pager, - size: 25, - color: textColor.withValues(alpha: 0.7), - ), + TextSpan(text: e.message.substring(0, e.offset)), + TextSpan( + text: e.message + .substring(e.offset, e.offset + e.length), + style: const TextStyle(fontWeight: FontWeight.bold), ), - const SizedBox( - width: 7, + TextSpan( + text: e.message.substring(e.offset + e.length), ), - getUsageDots(readingUses), ], ), - ], + ), + ); + }).toList(), + ), + ); + } else { + return const Column( + children: [ + SizedBox(height: 10), + CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ], + ); + } + }, + ); + } +} + +class LemmaUsageDots extends StatelessWidget { + final ConstructUses construct; + final LearningSkillsEnum category; + + final String tooltip; + final IconData icon; + + const LemmaUsageDots({ + required this.construct, + required this.category, + required this.tooltip, + required this.icon, + super.key, + }); + + /// Find lemma uses for the given exercise type, to create dot list + List sortedUses(LearningSkillsEnum category) { + final List useList = []; + for (final OneConstructUse use in construct.uses) { + if (use.useType.pointValue == 0) { + continue; + } + // If the use type matches the given category, save to list + // Usage with positive XP is saved as true, else false + if (category == use.useType.skillsEnumType) { + useList.add(use.useType.pointValue > 0); + } + } + return useList; + } + + @override + Widget build(BuildContext context) { + final List dots = []; + for (final bool use in sortedUses(category)) { + dots.add( + Container( + width: 15.0, + height: 15.0, + decoration: BoxDecoration( + color: use ? AppConfig.success : Colors.red, + shape: BoxShape.circle, + ), + ), + ); + } + + final Color textColor = Theme.of(context).brightness != Brightness.light + ? construct.lemmaCategory.color + : construct.lemmaCategory.darkColor; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Tooltip( + message: L10n.of(context).writingExercisesTooltip, + child: Icon( + icon, + size: 24, + color: textColor.withValues(alpha: 0.7), + ), + ), + const SizedBox(width: 8.0), + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 90), + child: SingleChildScrollView( + child: Wrap( + spacing: 3, + runSpacing: 5, + children: dots, ), ), ), ), - ), + ], ), ); } } + +class ExampleMessage { + final String message; + final int offset; + final int length; + + ExampleMessage({ + required this.message, + required this.offset, + required this.length, + }); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ExampleMessage && + other.message == message && + other.offset == offset && + other.length == length; + } + + @override + int get hashCode => message.hashCode ^ offset.hashCode ^ length.hashCode; +} diff --git a/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart b/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart index 6a8354d3a..ab49784b1 100644 --- a/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart +++ b/lib/pangea/toolbar/widgets/practice_activity/word_audio_button.dart @@ -8,13 +8,15 @@ import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart'; class WordAudioButton extends StatefulWidget { final String text; final TtsController ttsController; - final String eventID; + final String? eventID; + final double size; const WordAudioButton({ super.key, required this.text, required this.ttsController, - required this.eventID, + this.eventID, + this.size = 24, }); @override @@ -31,14 +33,8 @@ class WordAudioButtonState extends State { isSelected: _isPlaying, selectedIcon: const Icon(Icons.pause_outlined), color: _isPlaying ? Colors.white : null, - style: ButtonStyle( - backgroundColor: WidgetStateProperty.all( - _isPlaying - ? Theme.of(context).colorScheme.secondary - : Theme.of(context).colorScheme.primaryContainer, - ), - ), tooltip: _isPlaying ? L10n.of(context).stop : L10n.of(context).playAudio, + iconSize: widget.size, onPressed: () async { if (_isPlaying) { await widget.ttsController.stop();