From a71f519700688f5f5b4e7c6e2bcc1314e28d24eb Mon Sep 17 00:00:00 2001 From: ggurdin <46800240+ggurdin@users.noreply.github.com> Date: Wed, 12 Feb 2025 13:54:38 -0500 Subject: [PATCH] 1719 grammar detailed view in analytics (#1728) * feat: grammar analytics details page --------- Co-authored-by: wcjord <32568597+wcjord@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../analytics_details_popup.dart | 51 ++- .../analytics_details_popup_content.dart | 100 +++++ .../lemma_use_example_messages.dart | 4 - .../morph_analytics_view.dart | 151 ++++---- .../morph_details_view.dart | 174 +++++++++ .../vocab_details_view.dart | 347 ++++++++---------- .../analytics_misc/analytics_constants.dart | 3 + lib/pangea/common/network/urls.dart | 1 + .../morphs/morph_meaning/morph_info_repo.dart | 99 +++++ .../morph_meaning/morph_info_request.dart | 30 ++ .../morph_meaning/morph_info_response.dart | 112 ++++++ 11 files changed, 784 insertions(+), 288 deletions(-) create mode 100644 lib/pangea/analytics_details_popup/analytics_details_popup_content.dart create mode 100644 lib/pangea/analytics_details_popup/morph_details_view.dart create mode 100644 lib/pangea/morphs/morph_meaning/morph_info_repo.dart create mode 100644 lib/pangea/morphs/morph_meaning/morph_info_request.dart create mode 100644 lib/pangea/morphs/morph_meaning/morph_info_response.dart diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup.dart b/lib/pangea/analytics_details_popup/analytics_details_popup.dart index dc2b83550..d72be72c9 100644 --- a/lib/pangea/analytics_details_popup/analytics_details_popup.dart +++ b/lib/pangea/analytics_details_popup/analytics_details_popup.dart @@ -2,8 +2,10 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/analytics_details_popup/morph_analytics_view.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/morph_details_view.dart'; import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_view.dart'; import 'package:fluffychat/pangea/analytics_details_popup/vocab_details_view.dart'; +import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_identifier.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart'; import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart'; @@ -60,26 +62,45 @@ class AnalyticsPopupWrapperState extends State { ? () => Navigator.of(context).pop() : () => setConstructZoom(null), ), - actions: ConstructTypeEnum.values - .map( - (c) => IconButton( - icon: Icon(c.indicator.icon), - onPressed: () => setState(() { - localView = c; + actions: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 30.0, + width: 30.0, + child: InkWell( + child: Image.network( + '${AppConfig.assetsBaseURL}/${AnalyticsConstants.vocabIconFileName}', + ), + onTap: () => setState(() { + localView = ConstructTypeEnum.vocab; localConstructZoom = null; }), - isSelected: localView == c, - color: localView == c - ? Theme.of(context).brightness == Brightness.dark - ? AppConfig.primaryColorLight - : AppConfig.primaryColor - : null, ), - ) - .toList(), + ), + ), + Padding( + padding: const EdgeInsets.all(8.0), + child: SizedBox( + height: 30.0, + width: 30.0, + child: InkWell( + child: Image.network( + '${AppConfig.assetsBaseURL}/${AnalyticsConstants.morphIconFileName}', + ), + onTap: () => setState(() { + localView = ConstructTypeEnum.morph; + localConstructZoom = null; + }), + ), + ), + ), + ], ), body: localView == ConstructTypeEnum.morph - ? const MorphAnalyticsView() + ? localConstructZoom == null + ? MorphAnalyticsView(onConstructZoom: setConstructZoom) + : MorphDetailsView(constructId: localConstructZoom!) : localConstructZoom == null ? VocabAnalyticsView(onConstructZoom: setConstructZoom) : VocabDetailsView(constructId: localConstructZoom!), diff --git a/lib/pangea/analytics_details_popup/analytics_details_popup_content.dart b/lib/pangea/analytics_details_popup/analytics_details_popup_content.dart new file mode 100644 index 000000000..65f1e2081 --- /dev/null +++ b/lib/pangea/analytics_details_popup/analytics_details_popup_content.dart @@ -0,0 +1,100 @@ +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'; +import 'package:fluffychat/pangea/analytics_details_popup/lemma_usage_dots.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/lemma_use_example_messages.dart'; +import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_identifier.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_level_enum.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; +import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart'; + +class AnalyticsDetailsViewContent extends StatelessWidget { + final Widget title; + final Widget subtitle; + final Widget headerContent; + final Widget xpIcon; + final ConstructIdentifier constructId; + + const AnalyticsDetailsViewContent({ + required this.title, + required this.subtitle, + required this.xpIcon, + required this.headerContent, + required this.constructId, + super.key, + }); + + ConstructUses get construct => constructId.constructUses; + + @override + Widget build(BuildContext context) { + final Color textColor = Theme.of(context).brightness != Brightness.light + ? construct.lemmaCategory.color + : construct.lemmaCategory.darkColor; + + return SingleChildScrollView( + child: Column( + children: [ + title, + const SizedBox(height: 16.0), + subtitle, + const SizedBox(height: 16.0), + headerContent, + Padding( + padding: const EdgeInsets.symmetric(vertical: 16.0), + child: Image.network( + "${AppConfig.assetsBaseURL}/${AnalyticsConstants.popupDividerFileName}", + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + xpIcon, + const SizedBox(width: 16.0), + Text( + "${construct.points} XP", + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: textColor, + ), + ), + ], + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + 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, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart b/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart index c458c224b..2eff14e4d 100644 --- a/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart +++ b/lib/pangea/analytics_details_popup/lemma_use_example_messages.dart @@ -4,7 +4,6 @@ import 'package:collection/collection.dart'; import 'package:matrix/matrix.dart'; import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_level_enum.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart'; @@ -89,9 +88,6 @@ class LemmaUseExampleMessages extends StatelessWidget { vertical: 8, ), margin: const EdgeInsets.only(bottom: 8), - constraints: const BoxConstraints( - maxWidth: FluffyThemes.columnWidth * 1.5, - ), child: RichText( text: TextSpan( style: TextStyle( diff --git a/lib/pangea/analytics_details_popup/morph_analytics_view.dart b/lib/pangea/analytics_details_popup/morph_analytics_view.dart index 41c668ddf..d26af47a7 100644 --- a/lib/pangea/analytics_details_popup/morph_analytics_view.dart +++ b/lib/pangea/analytics_details_popup/morph_analytics_view.dart @@ -16,7 +16,10 @@ import 'package:fluffychat/widgets/matrix.dart'; import '../morphs/morph_repo.dart'; class MorphAnalyticsView extends StatelessWidget { + final void Function(ConstructIdentifier) onConstructZoom; + const MorphAnalyticsView({ + required this.onConstructZoom, super.key, }); @@ -42,6 +45,7 @@ class MorphAnalyticsView extends StatelessWidget { .map((tag) => tag.toLowerCase()) .toSet() ?? {}, + onConstructZoom: onConstructZoom, ) : const SizedBox.shrink(), ) @@ -56,11 +60,13 @@ class MorphAnalyticsView extends StatelessWidget { class MorphFeatureBox extends StatelessWidget { final String morphFeature; final Set allTags; + final void Function(ConstructIdentifier) onConstructZoom; const MorphFeatureBox({ super.key, required this.morphFeature, required this.allTags, + required this.onConstructZoom, }); String _categoryCopy( @@ -125,25 +131,32 @@ class MorphFeatureBox extends StatelessWidget { runSpacing: 16.0, children: allTags .map( - (morphTag) => MorphTagChip( - morphFeature: morphFeature, - morphTag: morphTag, - constructAnalytics: MatrixState.pangeaController + (morphTag) { + final id = ConstructIdentifier( + lemma: morphTag, + type: ConstructTypeEnum.morph, + category: morphFeature, + ); + + final analytics = MatrixState.pangeaController .getAnalytics.constructListModel - .getConstructUses( - ConstructIdentifier( - lemma: morphTag, - type: ConstructTypeEnum.morph, - category: morphFeature, - ), - ) ?? + .getConstructUses(id) ?? ConstructUses( lemma: morphTag, constructType: ConstructTypeEnum.morph, category: morphFeature, uses: [], - ), - ), + ); + + return MorphTagChip( + morphFeature: morphFeature, + morphTag: morphTag, + constructAnalytics: analytics, + onTap: analytics.points > 0 + ? () => onConstructZoom(id) + : null, + ); + }, ) .sortedBy( (chip) => chip.constructAnalytics.points, @@ -164,72 +177,78 @@ class MorphTagChip extends StatelessWidget { final String morphFeature; final String morphTag; final ConstructUses constructAnalytics; + final VoidCallback? onTap; const MorphTagChip({ super.key, required this.morphFeature, required this.morphTag, required this.constructAnalytics, + this.onTap, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Opacity( - opacity: constructAnalytics.points > 0 ? 1.0 : 0.3, - child: Container( - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(32.0), - gradient: constructAnalytics.points > 0 - ? LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [ - Colors.transparent, - constructAnalytics.lemmaCategory.color, - ], - ) - : null, - color: constructAnalytics.points > 0 ? null : theme.disabledColor, - ), - padding: const EdgeInsets.symmetric( - vertical: 4.0, - horizontal: 8.0, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - spacing: 8.0, - children: [ - SizedBox( - width: 28.0, - height: 28.0, - child: constructAnalytics.points > 0 || - Matrix.of(context).client.isSupportAccount - ? MorphIcon( - morphFeature: morphFeature, - morphTag: morphTag, - ) - : const Icon( - Icons.lock, - color: Colors.white, - ), - ), - Text( - getGrammarCopy( - category: morphFeature, - lemma: morphTag, - context: context, - ) ?? - morphTag, - style: TextStyle( - fontWeight: FontWeight.bold, - color: theme.brightness == Brightness.dark - ? Colors.white - : Colors.black, + return InkWell( + borderRadius: BorderRadius.circular(32.0), + onTap: onTap, + child: Opacity( + opacity: constructAnalytics.points > 0 ? 1.0 : 0.3, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(32.0), + gradient: constructAnalytics.points > 0 + ? LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.transparent, + constructAnalytics.lemmaCategory.color, + ], + ) + : null, + color: constructAnalytics.points > 0 ? null : theme.disabledColor, + ), + padding: const EdgeInsets.symmetric( + vertical: 4.0, + horizontal: 8.0, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8.0, + children: [ + SizedBox( + width: 28.0, + height: 28.0, + child: constructAnalytics.points > 0 || + Matrix.of(context).client.isSupportAccount + ? MorphIcon( + morphFeature: morphFeature, + morphTag: morphTag, + ) + : const Icon( + Icons.lock, + color: Colors.white, + ), + ), + Text( + getGrammarCopy( + category: morphFeature, + lemma: morphTag, + context: context, + ) ?? + morphTag, + style: TextStyle( + fontWeight: FontWeight.bold, + color: theme.brightness == Brightness.dark + ? Colors.white + : Colors.black, + ), ), - ), - ], + ], + ), ), ), ); diff --git a/lib/pangea/analytics_details_popup/morph_details_view.dart b/lib/pangea/analytics_details_popup/morph_details_view.dart new file mode 100644 index 000000000..4f014e593 --- /dev/null +++ b/lib/pangea/analytics_details_popup/morph_details_view.dart @@ -0,0 +1,174 @@ +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_misc/construct_identifier.dart'; +import 'package:fluffychat/pangea/analytics_misc/construct_level_enum.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/analytics_misc/text_loading_shimmer.dart'; +import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart'; +import 'package:fluffychat/pangea/morphs/morph_icon.dart'; +import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_repo.dart'; + +class MorphDetailsView extends StatelessWidget { + final ConstructIdentifier constructId; + + const MorphDetailsView({ + required this.constructId, + super.key, + }); + + ConstructUses get _construct => constructId.constructUses; + String get _morphFeature => constructId.category; + String get _morphTag => constructId.lemma; + + String _categoryCopy( + BuildContext context, + ) { + if (_morphFeature.toLowerCase() == "other") { + return L10n.of(context).other; + } + + return ConstructTypeEnum.morph.getDisplayCopy( + _morphFeature, + context, + ) ?? + _morphFeature; + } + + Future _getDefinition(BuildContext context) => MorphInfoRepo.get( + feature: _construct.category, + tag: _construct.lemma, + ).then((value) => value ?? L10n.of(context).meaningNotFound); + + @override + Widget build(BuildContext context) { + final Color textColor = Theme.of(context).brightness != Brightness.light + ? _construct.lemmaCategory.color + : _construct.lemmaCategory.darkColor; + + return AnalyticsDetailsViewContent( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 32.0, + height: 32.0, + child: MorphIcon( + morphFeature: _morphFeature, + morphTag: _morphTag, + ), + ), + const SizedBox(width: 10.0), + Text( + getGrammarCopy( + category: _morphFeature, + lemma: _morphTag, + context: context, + ) ?? + _morphTag, + style: Theme.of(context).textTheme.titleLarge, + ), + ], + ), + subtitle: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 24.0, + height: 24.0, + child: MorphIcon(morphFeature: _morphFeature, morphTag: null), + ), + const SizedBox(width: 10.0), + Text( + _categoryCopy(context), + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: textColor, + ), + ), + ], + ), + headerContent: Padding( + padding: const EdgeInsets.all(25.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: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + TextSpan( + text: " ${snapshot.data!}", + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ), + ); + } 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: CircleAvatar( + radius: 16.0, + backgroundColor: _construct.lemmaCategory.color, + child: const Icon( + Icons.star, + color: Colors.white, + size: 20.0, + ), + ), + constructId: constructId, + ); + } +} diff --git a/lib/pangea/analytics_details_popup/vocab_details_view.dart b/lib/pangea/analytics_details_popup/vocab_details_view.dart index b8e11c165..5df5a6b9d 100644 --- a/lib/pangea/analytics_details_popup/vocab_details_view.dart +++ b/lib/pangea/analytics_details_popup/vocab_details_view.dart @@ -3,13 +3,11 @@ 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/lemma_usage_dots.dart'; -import 'package:fluffychat/pangea/analytics_details_popup/lemma_use_example_messages.dart'; +import 'package:fluffychat/pangea/analytics_details_popup/analytics_details_popup_content.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_identifier.dart'; import 'package:fluffychat/pangea/analytics_misc/construct_level_enum.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/analytics_misc/learning_skills_enum.dart'; import 'package:fluffychat/pangea/common/widgets/customized_svg.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart'; @@ -32,29 +30,29 @@ class VocabDetailsView extends StatelessWidget { required this.constructId, }); - ConstructUses get construct => constructId.constructUses; + ConstructUses get _construct => constructId.constructUses; - String? get emoji => PangeaToken( + String? get _emoji => PangeaToken( text: PangeaTokenText( offset: 0, - content: construct.lemma, - length: construct.lemma.length, + content: _construct.lemma, + length: _construct.lemma.length, ), lemma: Lemma( - text: construct.lemma, + text: _construct.lemma, saveVocab: false, - form: construct.lemma, + form: _construct.lemma, ), - pos: construct.category, + pos: _construct.category, morph: {}, ).getEmoji(); /// Get string representing forms of the given lemma that have been used - String? get formString { + String? get _formString { // Get possible forms of lemma final constructs = MatrixState .pangeaController.getAnalytics.constructListModel - .getConstructUsesByLemma(construct.lemma); + .getConstructUsesByLemma(_construct.lemma); final forms = constructs .map((e) => e.uses) @@ -70,7 +68,7 @@ class VocabDetailsView extends StatelessWidget { } /// Fetch the meaning of the lemma - Future getDefinition(BuildContext context) async { + Future _getDefinition(BuildContext context) async { final lang2 = MatrixState.pangeaController.languageController.userL2?.langCode; if (lang2 == null) { @@ -79,12 +77,12 @@ class VocabDetailsView extends StatelessWidget { } final LemmaInfoRequest lemmaDefReq = LemmaInfoRequest( - partOfSpeech: construct.category, + partOfSpeech: _construct.category, lemmaLang: lang2, userL1: MatrixState.pangeaController.languageController.userL1?.langCode ?? LanguageKeys.defaultLanguage, - lemma: construct.lemma, + lemma: _construct.lemma, ); final LemmaInfoResponse res = await LemmaInfoRepo.get(lemmaDefReq); return res.meaning; @@ -93,118 +91,88 @@ class VocabDetailsView extends StatelessWidget { @override Widget build(BuildContext context) { final Color textColor = Theme.of(context).brightness != Brightness.light - ? construct.lemmaCategory.color - : construct.lemmaCategory.darkColor; + ? _construct.lemmaCategory.color + : _construct.lemmaCategory.darkColor; - return Scaffold( - appBar: AppBar( - leading: const SizedBox(), - 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: 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, - 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, + return AnalyticsDetailsViewContent( + title: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 42, + child: _emoji == null + ? Tooltip( + message: L10n.of(context).noEmojiSelectedTooltip, child: Icon( - Symbols.toys_and_games, - size: 23, + Icons.add_reaction_outlined, + size: 24, color: textColor.withValues(alpha: 0.7), ), - ), - const SizedBox(width: 10.0), - Text( - getGrammarCopy( - category: "pos", - lemma: construct.category, - context: context, - ) ?? - construct.category, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: textColor, - ), - ), - ], - ), - 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: Theme.of(context) - .textTheme - .bodyLarge - ?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: " ${snapshot.data!}", - style: Theme.of(context).textTheme.bodyLarge, - ), - ], + ) + : 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, + ttsController: TtsController(), + size: 24, + ), + ], + ), + 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), + ), + ), + const SizedBox(width: 10.0), + Text( + getGrammarCopy( + category: "pos", + lemma: _construct.category, + context: context, + ) ?? + _construct.category, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: textColor, + ), + ), + ], + ), + headerContent: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + 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, ), - ); - } else { - return Wrap( - children: [ - Text( - L10n.of(context).meaningSectionHeader, + children: [ + TextSpan( + text: L10n.of(context).meaningSectionHeader, style: Theme.of(context) .textTheme .bodyLarge @@ -212,100 +180,73 @@ class VocabDetailsView extends StatelessWidget { fontWeight: FontWeight.bold, ), ), - const SizedBox( - width: 10, - ), - const CircularProgressIndicator.adaptive( - strokeWidth: 2, + TextSpan( + text: " ${snapshot.data!}", + style: Theme.of(context).textTheme.bodyLarge, ), ], - ); - } - }, - ), - ), - ), - Padding( - padding: const EdgeInsets.all(5.0), - child: Align( - alignment: Alignment.topLeft, - child: RichText( - text: TextSpan( - style: Theme.of(context).textTheme.bodyLarge, - children: [ - TextSpan( - text: L10n.of(context).formSectionHeader, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - TextSpan( - text: - " ${formString ?? L10n.of(context).formsNotFound}", ), - ], - ), - ), - ), - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 20), - child: Divider( - height: 3, - color: textColor.withValues(alpha: 0.7), + ); + } else { + return Wrap( + children: [ + Text( + L10n.of(context).meaningSectionHeader, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox( + width: 10, + ), + const CircularProgressIndicator.adaptive( + strokeWidth: 2, + ), + ], + ); + } + }, ), ), - 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, + ), + Padding( + padding: const EdgeInsets.all(5.0), + child: Align( + alignment: Alignment.topLeft, + child: RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyLarge, + children: [ + TextSpan( + text: L10n.of(context).formSectionHeader, style: const TextStyle( - fontSize: 20, + fontWeight: FontWeight.bold, ), ), - ), - ), - Text( - "${construct.points} XP", - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: textColor, - ), + TextSpan( + text: + " ${_formString ?? L10n.of(context).formsNotFound}", + ), + ], ), - ], - ), - const SizedBox(height: 20), - 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, + ), ), - ], + ), + ], + ), + ), + xpIcon: CustomizedSvg( + svgUrl: _construct.lemmaCategory.svgURL, + colorReplacements: const {}, + errorIcon: Text( + _construct.lemmaCategory.emoji, + style: const TextStyle( + fontSize: 20, ), ), ), + constructId: constructId, ); } } diff --git a/lib/pangea/analytics_misc/analytics_constants.dart b/lib/pangea/analytics_misc/analytics_constants.dart index 87fee5acc..cff83fcc1 100644 --- a/lib/pangea/analytics_misc/analytics_constants.dart +++ b/lib/pangea/analytics_misc/analytics_constants.dart @@ -12,4 +12,7 @@ class AnalyticsConstants { static const String emojiForFlower = "🌸"; static const levelUpAudioFileName = "LevelUp_chime.mp3"; static const levelUpImageFileName = "LvL_Up_Full_Banner.png"; + static const popupDividerFileName = "divider.png"; + static const vocabIconFileName = "Vocabulary_icon.png"; + static const morphIconFileName = "grammar_icon.png"; } diff --git a/lib/pangea/common/network/urls.dart b/lib/pangea/common/network/urls.dart index 8608353ac..6851369a9 100644 --- a/lib/pangea/common/network/urls.dart +++ b/lib/pangea/common/network/urls.dart @@ -65,6 +65,7 @@ class PApiUrls { "${PApiUrls.choreoEndpoint}/practice"; static String lemmaDictionary = "${PApiUrls.choreoEndpoint}/lemma_definition"; + static String morphDictionary = "${PApiUrls.choreoEndpoint}/morph_meaning"; static String activityPlanGeneration = "${PApiUrls.choreoEndpoint}/activity_plan"; diff --git a/lib/pangea/morphs/morph_meaning/morph_info_repo.dart b/lib/pangea/morphs/morph_meaning/morph_info_repo.dart new file mode 100644 index 000000000..2f7d292ef --- /dev/null +++ b/lib/pangea/morphs/morph_meaning/morph_info_repo.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +import 'package:get_storage/get_storage.dart'; +import 'package:http/http.dart'; + +import 'package:fluffychat/pangea/common/config/environment.dart'; +import 'package:fluffychat/pangea/common/network/requests.dart'; +import 'package:fluffychat/pangea/common/network/urls.dart'; +import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart'; +import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_request.dart'; +import 'package:fluffychat/pangea/morphs/morph_meaning/morph_info_response.dart'; +import 'package:fluffychat/widgets/matrix.dart'; + +class _APICallCacheItem { + final DateTime time; + final Future future; + + _APICallCacheItem(this.time, this.future); +} + +class MorphInfoRepo { + static final GetStorage _morphMeaningStorage = + GetStorage('morph_meaning_storage'); + static final shortTermCache = {}; + static const int _cacheDurationMinutes = 1; + + static void set(MorphInfoRequest request, MorphInfoResponse response) { + _morphMeaningStorage.write(request.storageKey, response.toJson()); + } + + static Future _fetch(MorphInfoRequest request) async { + try { + final Requests req = Requests( + choreoApiKey: Environment.choreoApiKey, + accessToken: MatrixState.pangeaController.userController.accessToken, + ); + + final Response res = await req.post( + url: PApiUrls.morphDictionary, + body: request.toJson(), + ); + + final decodedBody = jsonDecode(utf8.decode(res.bodyBytes)); + final response = MorphInfoResponse.fromJson(decodedBody); + + set(request, response); + + return response; + } catch (e, s) { + debugPrint('Error fetching morph info: $e'); + return Future.error(e); + } + } + + static Future _get(MorphInfoRequest request) async { + request.userL1 == request.userL1.split('-').first; + request.userL2 == request.userL2.split('-').first; + + final cachedJson = _morphMeaningStorage.read(request.storageKey); + if (cachedJson != null) { + return MorphInfoResponse.fromJson(cachedJson); + } + + final _APICallCacheItem? cachedCall = shortTermCache[request.storageKey]; + if (cachedCall != null) { + if (DateTime.now().difference(cachedCall.time).inMinutes < + _cacheDurationMinutes) { + return cachedCall.future; + } else { + shortTermCache.remove(request.storageKey); + } + } + + final future = _fetch(request); + shortTermCache[request.storageKey] = + _APICallCacheItem(DateTime.now(), future); + return future; + } + + static Future get({ + required String feature, + required String tag, + }) async { + final res = await _get( + MorphInfoRequest( + userL1: + MatrixState.pangeaController.languageController.userL1?.langCode ?? + LanguageKeys.defaultLanguage, + userL2: + MatrixState.pangeaController.languageController.userL2?.langCode ?? + LanguageKeys.defaultLanguage, + ), + ); + + return res.getFeatureByCode(feature)?.getTagByCode(tag)?.l1Description; + } +} diff --git a/lib/pangea/morphs/morph_meaning/morph_info_request.dart b/lib/pangea/morphs/morph_meaning/morph_info_request.dart new file mode 100644 index 000000000..1d3eb2ea7 --- /dev/null +++ b/lib/pangea/morphs/morph_meaning/morph_info_request.dart @@ -0,0 +1,30 @@ +class MorphInfoRequest { + final String userL1; + final String userL2; + + MorphInfoRequest({ + required this.userL1, + required this.userL2, + }); + + Map toJson() { + return { + 'user_l1': userL1, + 'user_l2': userL2, + }; + } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is MorphInfoRequest && + userL1 == other.userL1 && + userL2 == other.userL2; + + @override + int get hashCode => userL1.hashCode ^ userL2.hashCode; + + String get storageKey { + return userL1 + userL2; + } +} diff --git a/lib/pangea/morphs/morph_meaning/morph_info_response.dart b/lib/pangea/morphs/morph_meaning/morph_info_response.dart new file mode 100644 index 000000000..287e98a4e --- /dev/null +++ b/lib/pangea/morphs/morph_meaning/morph_info_response.dart @@ -0,0 +1,112 @@ +import 'package:collection/collection.dart'; + +class MorphologicalTag { + final String code; + final String l2Title; + final String l1Title; + final String l1Description; + + MorphologicalTag({ + required this.code, + required this.l2Title, + required this.l1Title, + required this.l1Description, + }); + + factory MorphologicalTag.fromJson(Map json) { + return MorphologicalTag( + code: json['code'], + l2Title: json['l2_title'], + l1Title: json['l1_title'], + l1Description: json['l1_description'], + ); + } + + Map toJson() { + return { + 'code': code, + 'l2_title': l2Title, + 'l1_title': l1Title, + 'l1_description': l1Description, + }; + } +} + +class MorphologicalFeature { + final String code; + final String l2Title; + final String l1Title; + final List tags; + + MorphologicalFeature({ + required this.code, + required this.l2Title, + required this.l1Title, + required this.tags, + }); + + factory MorphologicalFeature.fromJson(Map json) { + final tagsFromJson = json['tags'] as List; + final List tagsList = + tagsFromJson.map((tag) => MorphologicalTag.fromJson(tag)).toList(); + + return MorphologicalFeature( + code: json['code'], + l2Title: json['l2_title'], + l1Title: json['l1_title'], + tags: tagsList, + ); + } + + Map toJson() { + return { + 'code': code, + 'l2_title': l2Title, + 'l1_title': l1Title, + 'tags': tags.map((tag) => tag.toJson()).toList(), + }; + } + + MorphologicalTag? getTagByCode(String code) { + return tags.firstWhereOrNull( + (tag) => tag.code.toLowerCase() == code.toLowerCase()); + } +} + +class MorphInfoResponse { + final String userL1; + final String userL2; + final List features; + + MorphInfoResponse({ + required this.userL1, + required this.userL2, + required this.features, + }); + + factory MorphInfoResponse.fromJson(Map json) { + final featuresFromJson = json['features'] as List; + final List featuresList = featuresFromJson + .map((feature) => MorphologicalFeature.fromJson(feature)) + .toList(); + + return MorphInfoResponse( + userL1: json['user_l1'], + userL2: json['user_l2'], + features: featuresList, + ); + } + + Map toJson() { + return { + 'user_l1': userL1, + 'user_l2': userL2, + 'features': features.map((feature) => feature.toJson()).toList(), + }; + } + + MorphologicalFeature? getFeatureByCode(String code) { + return features.firstWhereOrNull( + (feature) => feature.code.toLowerCase() == code.toLowerCase()); + } +}