diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index a37124f32..b82cca72c 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -4657,5 +4657,6 @@ "pleaseSelectALanguage": "Please select a language", "myBaseLanguage": "My base language", "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" + "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." } diff --git a/lib/pangea/constants/morph_categories_and_labels.dart b/lib/pangea/constants/morph_categories_and_labels.dart index 8c0b27674..f62d5ba31 100644 --- a/lib/pangea/constants/morph_categories_and_labels.dart +++ b/lib/pangea/constants/morph_categories_and_labels.dart @@ -213,6 +213,10 @@ IconData getIconForMorphFeature(String feature) { return Symbols.abc; case 'pos': return Symbols.toys_and_games; + case 'polarity': + return Icons.swap_vert; + case 'definite': + return Icons.check_circle_outline; default: debugger(when: kDebugMode); return Icons.help_outline; diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart index 79681baec..586620417 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart @@ -165,26 +165,38 @@ class ActivityRecordResponse { PracticeActivityModel practiceActivity, ConstructUseMetaData metadata, ) { - if (practiceActivity.activityType == ActivityTypeEnum.emoji) { - if (practiceActivity.targetTokens != null && - practiceActivity.targetTokens!.isNotEmpty) { - final token = practiceActivity.targetTokens!.first; - return [ - OneConstructUse( - lemma: token.lemma.text, - form: token.text.content, - constructType: ConstructTypeEnum.vocab, - useType: useType(practiceActivity.activityType), - metadata: metadata, - category: token.pos, - ), - ]; - } + if (practiceActivity.targetTokens == null || + practiceActivity.targetTokens!.isEmpty) { return []; } - if (practiceActivity.targetTokens == null) { - return []; + if (practiceActivity.activityType == ActivityTypeEnum.emoji) { + final token = practiceActivity.targetTokens!.first; + return [ + OneConstructUse( + lemma: token.lemma.text, + form: token.text.content, + constructType: ConstructTypeEnum.vocab, + useType: useType(practiceActivity.activityType), + metadata: metadata, + category: token.pos, + ), + ]; + } + + if (practiceActivity.activityType == ActivityTypeEnum.morphId) { + return practiceActivity.tgtConstructs + .map( + (token) => OneConstructUse( + lemma: token.lemma, + form: practiceActivity.targetTokens!.first.text.content, + constructType: ConstructTypeEnum.morph, + useType: useType(practiceActivity.activityType), + metadata: metadata, + category: token.category, + ), + ) + .toList(); } final uses = practiceActivity.targetTokens! diff --git a/lib/pangea/repo/lemma_definition_repo.dart b/lib/pangea/repo/lemma_definition_repo.dart index 7d0b728e6..7a6df90fd 100644 --- a/lib/pangea/repo/lemma_definition_repo.dart +++ b/lib/pangea/repo/lemma_definition_repo.dart @@ -134,7 +134,9 @@ class LemmaDictionaryRepo { /// From the cache, get a random set of cached definitions that are not for a specific lemma static List getDistractorDefinitions( - LemmaDefinitionRequest req, int count) { + LemmaDefinitionRequest req, + int count, + ) { _clearExpiredEntries(); final List definitions = []; diff --git a/lib/pangea/repo/practice/practice_repo.dart b/lib/pangea/repo/practice/practice_repo.dart index 518ac6378..f1caec063 100644 --- a/lib/pangea/repo/practice/practice_repo.dart +++ b/lib/pangea/repo/practice/practice_repo.dart @@ -19,6 +19,7 @@ import 'package:fluffychat/pangea/repo/practice/morph_activity_generator.dart'; import 'package:fluffychat/pangea/repo/practice/word_meaning_activity_generator.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; import 'package:http/http.dart'; import 'package:matrix/matrix.dart'; @@ -119,6 +120,7 @@ class PracticeGenerationController { Future _routePracticeActivity({ required String accessToken, required MessageActivityRequest req, + required BuildContext context, }) async { // some activities we'll get from the server and others we'll generate locally switch (req.targetType) { @@ -129,7 +131,7 @@ class PracticeGenerationController { case ActivityTypeEnum.morphId: return _morph.get(req); case ActivityTypeEnum.wordMeaning: - return _wordMeaning.get(req); + return _wordMeaning.get(req, context); case ActivityTypeEnum.wordFocusListening: case ActivityTypeEnum.hiddenWordListening: return _fetchFromServer( @@ -142,6 +144,7 @@ class PracticeGenerationController { Future getPracticeActivity( MessageActivityRequest req, PangeaMessageEvent event, + BuildContext context, ) async { final int cacheKey = req.hashCode; @@ -154,6 +157,7 @@ class PracticeGenerationController { final MessageActivityResponse res = await _routePracticeActivity( accessToken: _pangeaController.userController.accessToken, req: req, + context: context, ); // TODO resolve some wierdness here whereby the activity can be null but then... it's not diff --git a/lib/pangea/repo/practice/word_meaning_activity_generator.dart b/lib/pangea/repo/practice/word_meaning_activity_generator.dart index 4f5b20fed..a97792dec 100644 --- a/lib/pangea/repo/practice/word_meaning_activity_generator.dart +++ b/lib/pangea/repo/practice/word_meaning_activity_generator.dart @@ -4,10 +4,13 @@ import 'package:fluffychat/pangea/models/practice_activities.dart/message_activi import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/repo/lemma_definition_repo.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; class WordMeaningActivityGenerator { Future get( MessageActivityRequest req, + BuildContext context, ) async { final ConstructIdentifier lemmaId = ConstructIdentifier( lemma: req.targetTokens[0].lemma.text, @@ -29,6 +32,11 @@ class WordMeaningActivityGenerator { final choices = LemmaDictionaryRepo.getDistractorDefinitions(lemmaDefReq, 3); + if (!choices.contains(res.definition)) { + choices.add(res.definition); + choices.shuffle(); + } + return MessageActivityResponse( activity: PracticeActivityModel( tgtConstructs: [lemmaId], @@ -36,7 +44,7 @@ class WordMeaningActivityGenerator { langCode: req.userL2, activityType: ActivityTypeEnum.wordMeaning, content: ActivityContent( - question: "?", + question: "${L10n.of(context).definition}?", choices: choices, answers: [res.definition], spanDisplayDetails: null, diff --git a/lib/pangea/widgets/chat/message_audio_card.dart b/lib/pangea/widgets/chat/message_audio_card.dart index 939d8bbe7..e2ef52ced 100644 --- a/lib/pangea/widgets/chat/message_audio_card.dart +++ b/lib/pangea/widgets/chat/message_audio_card.dart @@ -202,7 +202,6 @@ class MessageAudioCardState extends State { ) : const CardErrorWidget( error: "Null audio file in message_audio_card", - maxWidth: AppConfig.toolbarMinWidth, ), ), ], diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index 0ba5b96fa..498d37427 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -110,11 +110,13 @@ class MessageOverlayController extends State void _updateSelectedSpan(PangeaTokenText selectedSpan) { _selectedSpan = selectedSpan; - widget.chatController.choreographer.tts.tryToSpeak( - selectedSpan.content, - context, - widget._pangeaMessageEvent?.eventId, - ); + if (!(messageAnalyticsEntry?.hasHiddenWordActivity ?? false)) { + widget.chatController.choreographer.tts.tryToSpeak( + selectedSpan.content, + context, + widget._pangeaMessageEvent?.eventId, + ); + } // if a token is selected, then the toolbar should be in wordZoom mode if (toolbarMode != MessageMode.wordZoom) { diff --git a/lib/pangea/widgets/chat/message_token_text.dart b/lib/pangea/widgets/chat/message_token_text.dart index 312c6f16c..6f07e0d63 100644 --- a/lib/pangea/widgets/chat/message_token_text.dart +++ b/lib/pangea/widgets/chat/message_token_text.dart @@ -67,13 +67,16 @@ class MessageTokenText extends StatelessWidget { final hideContent = messageAnalyticsEntry?.isTokenInHiddenWordActivity(token) ?? false; + final hasHiddenContent = + messageAnalyticsEntry?.hasHiddenWordActivity ?? false; + if (globalIndex < startIndex) { tokenPositions.add( TokenPosition( start: globalIndex, end: startIndex, hideContent: false, - highlight: _isSelected?.call(token) ?? false, + highlight: (_isSelected?.call(token) ?? false) && !hasHiddenContent, ), ); } @@ -84,7 +87,9 @@ class MessageTokenText extends StatelessWidget { end: endIndex, token: token, hideContent: hideContent, - highlight: (_isSelected?.call(token) ?? false) && !hideContent, + highlight: (_isSelected?.call(token) ?? false) && + !hideContent && + !hasHiddenContent, ), ); globalIndex = endIndex; diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 84d90a5e6..b70fae677 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -13,6 +13,7 @@ import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_ca import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; import 'package:matrix/matrix_api_lite/model/message_types.dart'; const double minCardHeight = 70; @@ -73,11 +74,11 @@ class MessageToolbar extends StatelessWidget { messageEvent: pangeaMessageEvent, ); case MessageMode.noneSelected: - return const SizedBox(); + return Text(L10n.of(context).clickWordsInstructions); case MessageMode.practiceActivity: case MessageMode.wordZoom: if (overlayController.selectedToken == null) { - return const SizedBox(); + return Text(L10n.of(context).clickWordsInstructions); } return WordZoomWidget( token: overlayController.selectedToken!, diff --git a/lib/pangea/widgets/igc/card_error_widget.dart b/lib/pangea/widgets/igc/card_error_widget.dart index 81066ad13..f8d13bf90 100644 --- a/lib/pangea/widgets/igc/card_error_widget.dart +++ b/lib/pangea/widgets/igc/card_error_widget.dart @@ -9,14 +9,16 @@ class CardErrorWidget extends StatelessWidget { final Object error; final Choreographer? choreographer; final int? offset; - final double? maxWidth; + final double maxWidth; + final double padding; const CardErrorWidget({ super.key, required this.error, this.choreographer, this.offset, - this.maxWidth, + this.maxWidth = 275, + this.padding = 8, }); @override @@ -24,8 +26,8 @@ class CardErrorWidget extends StatelessWidget { final ErrorCopy errorCopy = ErrorCopy(context, error); return Container( - padding: const EdgeInsets.all(8), - constraints: const BoxConstraints(maxWidth: 275), + padding: EdgeInsets.all(padding), + constraints: BoxConstraints(maxWidth: maxWidth), child: Column( crossAxisAlignment: CrossAxisAlignment.center, children: [ @@ -36,7 +38,7 @@ class CardErrorWidget extends StatelessWidget { cursorOffset: offset, ), ), - const SizedBox(height: 12.0), + const SizedBox(height: 6.0), Padding( padding: const EdgeInsets.all(12), child: Text( diff --git a/lib/pangea/widgets/practice_activity/emoji_practice_button.dart b/lib/pangea/widgets/practice_activity/emoji_practice_button.dart index 1f5d533a4..f415ee4db 100644 --- a/lib/pangea/widgets/practice_activity/emoji_practice_button.dart +++ b/lib/pangea/widgets/practice_activity/emoji_practice_button.dart @@ -1,36 +1,21 @@ import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/word_zoom_activity_button.dart'; import 'package:flutter/material.dart'; -class EmojiPracticeButton extends StatefulWidget { +class EmojiPracticeButton extends StatelessWidget { final PangeaToken token; final VoidCallback onPressed; - - final String? emoji; - final Function(String) setEmoji; + final bool isSelected; const EmojiPracticeButton({ required this.token, required this.onPressed, - this.emoji, - required this.setEmoji, + this.isSelected = false, super.key, }); - @override - EmojiPracticeButtonState createState() => EmojiPracticeButtonState(); -} - -class EmojiPracticeButtonState extends State { - @override - void didUpdateWidget(covariant EmojiPracticeButton oldWidget) { - super.didUpdateWidget(oldWidget); - if (widget.token != oldWidget.token) { - setState(() {}); - } - } - - bool get _shouldDoActivity => widget.token.shouldDoActivity( + bool get _shouldDoActivity => token.shouldDoActivity( a: ActivityTypeEnum.emoji, feature: null, tag: null, @@ -38,26 +23,16 @@ class EmojiPracticeButtonState extends State { @override Widget build(BuildContext context) { - final emoji = widget.token.getEmoji(); - return SizedBox( - height: 40, - width: 40, - child: _shouldDoActivity || emoji != null - ? Opacity( - opacity: _shouldDoActivity ? 0.5 : 1, - child: IconButton( - onPressed: () { - widget.onPressed(); - if (widget.emoji == null && emoji != null) { - widget.setEmoji(emoji); - } - }, - icon: emoji == null - ? const Icon(Icons.add_reaction_outlined) - : Text(emoji), - ), - ) - : const SizedBox.shrink(), - ); + final emoji = token.getEmoji(); + return _shouldDoActivity || emoji != null + ? WordZoomActivityButton( + icon: emoji == null + ? const Icon(Icons.add_reaction_outlined) + : Text(emoji), + isSelected: isSelected, + onPressed: onPressed, + opacity: _shouldDoActivity ? 0.5 : 1, + ) + : const SizedBox(width: 40); } } diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index 31cda6133..7b65604bf 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -147,9 +147,21 @@ class MultipleChoiceActivityState extends State { // If the selected choice is correct, send the record and get the next activity if (widget.currentActivity.content.isCorrect(value, index)) { - MatrixState.pangeaController.getAnalytics.analyticsStream.stream.first - .then((_) { - widget.practiceCardController.onActivityFinish(correctAnswer: value); + // If the activity is an emoji activity, set the emoji value + if (widget.currentActivity.activityType == ActivityTypeEnum.emoji) { + if (widget.currentActivity.targetTokens?.length != 1) { + debugger(when: kDebugMode); + } else { + widget.currentActivity.targetTokens!.first.setEmoji(value); + } + } + + // The next entry in the analytics stream should be from the above putAnalytics.setState. + // So we can wait for the stream to update before calling onActivityFinish. + final streamFuture = MatrixState + .pangeaController.getAnalytics.analyticsStream.stream.first; + streamFuture.then((_) { + widget.practiceCardController.onActivityFinish(); }); } diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 3a7f69d79..bcc7632f6 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -18,6 +18,7 @@ import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/widgets/animations/gain_points.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; import 'package:fluffychat/pangea/widgets/chat/toolbar_content_loading_indicator.dart'; +import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; import 'package:fluffychat/pangea/widgets/word_zoom/word_zoom_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; @@ -65,6 +66,7 @@ class PracticeActivityCardState extends State { PracticeGenerationController(); PangeaController get pangeaController => MatrixState.pangeaController; + String? _error; @override void initState() { @@ -96,6 +98,7 @@ class PracticeActivityCardState extends State { Future _fetchActivity({ ActivityQualityFeedback? activityFeedback, }) async { + _error = null; if (!mounted || !pangeaController.languageController.languagesSet || widget.overlayController.messageAnalyticsEntry == null) { @@ -134,6 +137,7 @@ class PracticeActivityCardState extends State { }, ); debugger(when: kDebugMode); + _error = e.toString(); } finally { _updateFetchingActivity(false); } @@ -152,14 +156,18 @@ class PracticeActivityCardState extends State { final existingActivity = widget.pangeaMessageEvent.practiceActivities.firstWhereOrNull( (activity) { - final sameActivity = activity.practiceActivity.targetTokens != null && - activity.practiceActivity.activityType == type && - activity.practiceActivity.targetTokens! - .map((t) => t.vocabConstructID.string) - .toSet() - .containsAll( - tokens.map((t) => t.vocabConstructID.string).toSet(), - ); + final sameActivity = + activity.practiceActivity.content.choices.toSet().containsAll( + activity.practiceActivity.content.answers.toSet(), + ) && + activity.practiceActivity.targetTokens != null && + activity.practiceActivity.activityType == type && + activity.practiceActivity.targetTokens! + .map((t) => t.vocabConstructID.string) + .toSet() + .containsAll( + tokens.map((t) => t.vocabConstructID.string).toSet(), + ); if (type != ActivityTypeEnum.morphId || sameActivity == false) { return sameActivity; } @@ -198,6 +206,7 @@ class PracticeActivityCardState extends State { await practiceGenerationController.getPracticeActivity( req, widget.pangeaMessageEvent, + context, ); if (activityResponse.activity == null) return null; @@ -213,12 +222,14 @@ class PracticeActivityCardState extends State { timeStamp: DateTime.now(), ); + final Duration _savorTheJoyDuration = const Duration(seconds: 1); + Future _savorTheJoy() async { try { debugger(when: savoringTheJoy && kDebugMode); if (mounted) setState(() => savoringTheJoy = true); - await Future.delayed(const Duration(seconds: 1)); + await Future.delayed(_savorTheJoyDuration); if (mounted) setState(() => savoringTheJoy = false); } catch (e, s) { debugger(when: kDebugMode); @@ -238,23 +249,23 @@ class PracticeActivityCardState extends State { /// Saves the completion record and sends it to the server. /// Fetches a new activity if there are any left to complete. /// Exits the practice flow if there are no more activities. - void onActivityFinish({String? correctAnswer}) async { + void onActivityFinish() async { try { if (currentCompletionRecord == null || currentActivity == null) { debugger(when: kDebugMode); return; } + widget.wordDetailsController?.onActivityFinish( + savorTheJoyDuration: _savorTheJoyDuration, + ); + widget.overlayController.onActivityFinish(); pangeaController.activityRecordController.completeActivity( widget.pangeaMessageEvent.eventId, ); await _savorTheJoy(); - widget.wordDetailsController?.onActivityFinish( - activityType: currentActivity!.activityType, - correctAnswer: correctAnswer, - ); } catch (e, s) { _onError(); debugger(when: kDebugMode); @@ -301,9 +312,20 @@ class PracticeActivityCardState extends State { @override Widget build(BuildContext context) { + if (_error != null) { + return CardErrorWidget( + error: _error!, + maxWidth: 500, + ); + } + if (!fetchingActivity && currentActivity == null) { debugPrint("don't think we should be here"); debugger(when: kDebugMode); + return CardErrorWidget( + error: _error!, + maxWidth: 500, + ); } return Stack( diff --git a/lib/pangea/widgets/practice_activity/word_zoom_activity_button.dart b/lib/pangea/widgets/practice_activity/word_zoom_activity_button.dart new file mode 100644 index 000000000..7dda78b39 --- /dev/null +++ b/lib/pangea/widgets/practice_activity/word_zoom_activity_button.dart @@ -0,0 +1,66 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class WordZoomActivityButton extends StatelessWidget { + final Widget icon; + final bool isSelected; + final VoidCallback onPressed; + + final String? tooltip; + final double? opacity; + + const WordZoomActivityButton({ + required this.icon, + required this.isSelected, + required this.onPressed, + this.tooltip, + this.opacity, + super.key, + }); + + @override + Widget build(BuildContext context) { + Widget buttonContent = IconButton( + onPressed: onPressed, + icon: icon, + color: isSelected ? Theme.of(context).colorScheme.primary : null, + style: IconButton.styleFrom( + backgroundColor: isSelected + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.25) + : null, + ), + ); + + if (opacity != null) { + buttonContent = Opacity( + opacity: opacity!, + child: buttonContent, + ); + } + + if (tooltip != null) { + buttonContent = Tooltip( + message: tooltip!, + child: buttonContent, + ); + } + + return Badge( + offset: kIsWeb ? null : const Offset(-1, 1), + isLabelVisible: isSelected, + label: SizedBox( + height: 10, + width: 10, + child: IconButton( + onPressed: onPressed, + icon: const Icon(Icons.close, size: 10), + padding: const EdgeInsets.all(0.0), + ), + ), + backgroundColor: + Theme.of(context).colorScheme.primary.withValues(alpha: 0.5), + padding: const EdgeInsets.all(0), + child: buttonContent, + ); + } +} diff --git a/lib/pangea/widgets/word_zoom/contextual_translation_widget.dart b/lib/pangea/widgets/word_zoom/contextual_translation_widget.dart index 5ddb71fdc..ad9efa77d 100644 --- a/lib/pangea/widgets/word_zoom/contextual_translation_widget.dart +++ b/lib/pangea/widgets/word_zoom/contextual_translation_widget.dart @@ -1,100 +1,57 @@ import 'package:fluffychat/pangea/constants/language_constants.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/repo/lemma_definition_repo.dart'; +import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_gen/gen_l10n/l10n.dart'; -class ContextualTranslationWidget extends StatefulWidget { +class ContextualTranslationWidget extends StatelessWidget { final PangeaToken token; - final String fullText; final String langCode; - final VoidCallback onPressed; - - final String? definition; - final Function(String) setDefinition; const ContextualTranslationWidget({ super.key, required this.token, - required this.fullText, required this.langCode, - required this.onPressed, - required this.setDefinition, - this.definition, }); - @override - ContextualTranslationWidgetState createState() => - ContextualTranslationWidgetState(); -} - -class ContextualTranslationWidgetState - extends State { - @override - void initState() { - super.initState(); - if (widget.definition == null) { - _fetchDefinition(); - } - } - - @override - void didUpdateWidget(covariant ContextualTranslationWidget oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.token != widget.token && widget.definition == null) { - _fetchDefinition(); - } - } - - Future _fetchDefinition() async { - // final FullTextTranslationResponseModel response = - // await FullTextTranslationRepo.translate( - // accessToken: MatrixState.pangeaController.userController.accessToken, - // request: FullTextTranslationRequestModel( - // text: widget.fullText, - // tgtLang: - // MatrixState.pangeaController.languageController.userL1?.langCode ?? - // LanguageKeys.defaultLanguage, - // userL2: - // MatrixState.pangeaController.languageController.userL2?.langCode ?? - // LanguageKeys.defaultLanguage, - // userL1: - // MatrixState.pangeaController.languageController.userL1?.langCode ?? - // LanguageKeys.defaultLanguage, - // offset: widget.token.text.offset, - // length: widget.token.text.length, - // deepL: false, - // ), - // ); - // widget.setDefinition(response.bestTranslation); + Future _fetchDefinition() async { final LemmaDefinitionRequest lemmaDefReq = LemmaDefinitionRequest( - lemma: widget.token.lemma.text, - partOfSpeech: widget.token.pos, + lemma: token.lemma.text, + partOfSpeech: token.pos, /// This assumes that the user's L2 is the language of the lemma - lemmaLang: widget.langCode, + lemmaLang: langCode, userL1: MatrixState.pangeaController.languageController.userL1?.langCode ?? LanguageKeys.defaultLanguage, ); final res = await LemmaDictionaryRepo.get(lemmaDefReq); - - widget.setDefinition(res.definition); + return res.definition; } @override Widget build(BuildContext context) { - return Center( - child: SizedBox( - height: 60, - // child: IconButton( - // iconSize: 30, - // onPressed: widget.onPressed, - // icon: const Icon(Symbols.dictionary), - // ), - child: Text(widget.definition ?? "..."), - ), + return FutureBuilder( + future: _fetchDefinition(), + builder: (context, snapshot) { + return Center( + child: Padding( + padding: const EdgeInsets.all(16), + child: snapshot.connectionState != ConnectionState.done + ? const CircularProgressIndicator() + : snapshot.hasError + ? CardErrorWidget( + error: L10n.of(context).oopsSomethingWentWrong, + padding: 0, + maxWidth: 500, + ) + : Text(snapshot.data ?? "..."), + ), + ); + }, ); } } diff --git a/lib/pangea/widgets/word_zoom/lemma_widget.dart b/lib/pangea/widgets/word_zoom/lemma_widget.dart index 4c5543695..9461ba759 100644 --- a/lib/pangea/widgets/word_zoom/lemma_widget.dart +++ b/lib/pangea/widgets/word_zoom/lemma_widget.dart @@ -1,35 +1,25 @@ import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/word_zoom_activity_button.dart'; import 'package:flutter/material.dart'; class LemmaWidget extends StatelessWidget { final PangeaToken token; final VoidCallback onPressed; - - final String? lemma; - final Function(String) setLemma; + final bool isSelected; const LemmaWidget({ super.key, required this.token, required this.onPressed, - this.lemma, - required this.setLemma, + this.isSelected = false, }); @override Widget build(BuildContext context) { - return SizedBox( - width: 40, - height: 40, - child: IconButton( - onPressed: () { - onPressed(); - if (lemma == null) { - setLemma(token.lemma.text); - } - }, - icon: Text(token.xpEmoji), - ), + return WordZoomActivityButton( + icon: Text(token.xpEmoji), + isSelected: isSelected, + onPressed: onPressed, ); } } diff --git a/lib/pangea/widgets/word_zoom/morphological_widget.dart b/lib/pangea/widgets/word_zoom/morphological_widget.dart index 3c8b37829..fbb0f2d4d 100644 --- a/lib/pangea/widgets/word_zoom/morphological_widget.dart +++ b/lib/pangea/widgets/word_zoom/morphological_widget.dart @@ -1,9 +1,7 @@ import 'package:fluffychat/pangea/constants/morph_categories_and_labels.dart'; import 'package:fluffychat/pangea/enum/analytics/morph_categories_enum.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; -import 'package:fluffychat/pangea/repo/practice/morph_activity_generator.dart'; -import 'package:fluffychat/pangea/utils/error_handler.dart'; -import 'package:fluffychat/widgets/matrix.dart'; +import 'package:fluffychat/pangea/widgets/practice_activity/word_zoom_activity_button.dart'; import 'package:flutter/material.dart'; class ActivityMorph { @@ -22,63 +20,49 @@ class MorphologicalListWidget extends StatelessWidget { final PangeaToken token; final String? selectedMorphFeature; final Function(String?) setMorphFeature; - final int completedActivities; const MorphologicalListWidget({ super.key, required this.selectedMorphFeature, required this.token, required this.setMorphFeature, - required this.completedActivities, }); List get _visibleMorphs { - // we always start with the part of speech - final visibleMorphs = [ - ActivityMorph( - morphFeature: "pos", - morphTag: token.pos, - revealed: !token.shouldDoPosActivity, - // revealed: !shouldDoActivity || !canGenerateDistractors, - ), - ]; - - // each pos has a defined set of morphological features to display and do activities for - final List seq = MorphActivityGenerator().getSequence( - MatrixState.pangeaController.languageController.userL2?.langCode, - token.pos, - ); - - for (final String feature in seq) { - // don't add any more if the last one is not revealed yet - if (!visibleMorphs.last.revealed) { - break; - } - - // check that the feature is in token.morph - if (!token.morph.containsKey(feature)) { - ErrorHandler.logError( - m: "Morphological feature suggested for pos but not found in token", - data: { - "feature": feature, - "token": token, - "lang_code": MatrixState - .pangeaController.languageController.userL2?.langCode, - }, - ); - continue; - } - - visibleMorphs.add( - ActivityMorph( - morphFeature: feature, - morphTag: token.morph[feature], - revealed: !token.shouldDoMorphActivity(feature), - ), + final activityMorphs = token.morph.entries.map((entry) { + return ActivityMorph( + morphFeature: entry.key, + morphTag: entry.value, + revealed: !token.shouldDoMorphActivity(entry.key), ); - } + }).toList(); - return visibleMorphs; + activityMorphs.sort((a, b) { + if (a.morphFeature.toLowerCase() == "pos") { + return -1; + } else if (b.morphFeature.toLowerCase() == "pos") { + return 1; + } + + if (a.revealed && !b.revealed) { + return -1; + } else if (!a.revealed && b.revealed) { + return 1; + } + + return a.morphFeature.compareTo(b.morphFeature); + }); + + final lastRevealedIndex = + activityMorphs.lastIndexWhere((morph) => morph.revealed); + + if (lastRevealedIndex == -1) { + return activityMorphs; + } else if (lastRevealedIndex >= (activityMorphs.length - 1)) { + return activityMorphs; + } else { + return activityMorphs.take(lastRevealedIndex + 2).toList(); + } } @override @@ -119,23 +103,15 @@ class MorphologicalActivityButton extends StatelessWidget { @override Widget build(BuildContext context) { - return Column( - children: [ - Tooltip( - message: getMorphologicalCategoryCopy( - morphCategory, - context, - ), - child: Opacity( - opacity: isSelected ? 1 : 0.5, - child: IconButton( - onPressed: () => onPressed(morphCategory), - icon: Icon(icon), - color: isSelected ? Theme.of(context).colorScheme.primary : null, - ), - ), - ), - ], + return WordZoomActivityButton( + icon: Icon(icon), + isSelected: isSelected, + onPressed: () => onPressed(morphCategory), + tooltip: getMorphologicalCategoryCopy( + morphCategory, + context, + ), + opacity: (isSelected || !isUnlocked) ? 1 : 0.5, ); } } diff --git a/lib/pangea/widgets/word_zoom/word_zoom_widget.dart b/lib/pangea/widgets/word_zoom/word_zoom_widget.dart index 76254cad7..ae82c9060 100644 --- a/lib/pangea/widgets/word_zoom/word_zoom_widget.dart +++ b/lib/pangea/widgets/word_zoom/word_zoom_widget.dart @@ -17,6 +17,23 @@ import 'package:flutter/material.dart'; enum WordZoomSelection { translation, emoji, + lemma, + morph, +} + +extension on WordZoomSelection { + ActivityTypeEnum get activityType { + switch (this) { + case WordZoomSelection.translation: + return ActivityTypeEnum.wordMeaning; + case WordZoomSelection.emoji: + return ActivityTypeEnum.emoji; + case WordZoomSelection.lemma: + return ActivityTypeEnum.lemmaId; + case WordZoomSelection.morph: + return ActivityTypeEnum.morphId; + } + } } class WordZoomWidget extends StatefulWidget { @@ -38,48 +55,29 @@ class WordZoomWidget extends StatefulWidget { } class WordZoomWidgetState extends State { - ActivityTypeEnum _activityType = ActivityTypeEnum.wordMeaning; + /// The currently selected word zoom activity type. + /// If an activity should be shown for this type, shows that activity. + /// If not, shows the info related to that activity type. + /// Defaults to the lemma translation. + WordZoomSelection _selectionType = WordZoomSelection.translation; - // morphological activities + /// If doing a morphological activity, this is the selected morph feature. String? _selectedMorphFeature; - /// used to trigger a rebuild of the morph activity - /// button when a morph activity is completed - int completedMorphActivities = 0; + /// If true, the activity will be shown regardless of shouldDoActivity. + /// Used to show the practice activity card's savor the joy animation. + /// (Analytics sending triggers the point gain animation, do also + /// causes shouldDoActivity to be false. This is a workaround.) + bool _forceShowActivity = false; - // defintion activities - String? _definition; - - // lemma activities - String? _lemma; - - // emoji activities - String? _emoji; - - // whether activity type can be generated - Map canGenerateActivity = { - ActivityTypeEnum.morphId: true, - ActivityTypeEnum.wordMeaning: true, - ActivityTypeEnum.lemmaId: false, - ActivityTypeEnum.emoji: true, - }; - - Future _initCanGenerateActivity() async { - widget.token.canGenerateDistractors(ActivityTypeEnum.lemmaId).then((value) { - if (mounted) { - setState(() { - canGenerateActivity[ActivityTypeEnum.lemmaId] = value; - }); - } - }); - - // if learner should do translation activity then setActivityType to wordMeaning - } + // The function to determine if lemma distractors can be generated + // is computationally expensive, so we only do it once + bool canGenerateLemmaActivity = false; @override void initState() { super.initState(); - _initCanGenerateActivity(); + _setCanGenerateLemmaActivity(); } @override @@ -87,88 +85,81 @@ class WordZoomWidgetState extends State { super.didUpdateWidget(oldWidget); if (widget.token != oldWidget.token) { _clean(); - _initCanGenerateActivity(); + _setCanGenerateLemmaActivity(); } } void _clean() { if (mounted) { setState(() { - _activityType = ActivityTypeEnum.wordMeaning; + _selectionType = WordZoomSelection.translation; _selectedMorphFeature = null; - _definition = null; - _lemma = null; - _emoji = null; + _forceShowActivity = false; }); } } - void _setSelectedMorphFeature(String? feature) { - _selectedMorphFeature = _selectedMorphFeature == feature ? null : feature; - _setActivityType( - _selectedMorphFeature == null - ? ActivityTypeEnum.wordMeaning - : ActivityTypeEnum.morphId, - ); + void _setCanGenerateLemmaActivity() { + widget.token.canGenerateDistractors(ActivityTypeEnum.lemmaId).then((value) { + if (mounted) setState(() => canGenerateLemmaActivity = value); + }); } - void _setActivityType(ActivityTypeEnum activityType) { - if (mounted) setState(() => _activityType = activityType); - } - - void _setDefinition(String definition) { - if (mounted) setState(() => _definition = definition); - } - - void _setLemma(String lemma) { - if (mounted) setState(() => _lemma = lemma); - } - - void _setEmoji(String emoji) { - if (mounted) setState(() => _emoji = emoji); - } - - void onActivityFinish({ - required ActivityTypeEnum activityType, - String? correctAnswer, - }) { - switch (activityType) { - case ActivityTypeEnum.morphId: - if (mounted) setState(() => completedMorphActivities++); - break; - case ActivityTypeEnum.wordMeaning: - if (correctAnswer == null) return; - _setDefinition(correctAnswer); - break; - case ActivityTypeEnum.lemmaId: - if (correctAnswer == null) return; - _setLemma(correctAnswer); - break; - case ActivityTypeEnum.emoji: - if (correctAnswer == null) return; - widget.token - .setEmoji(correctAnswer) - .then((_) => _setEmoji(correctAnswer)); - break; - default: - break; + void _setSelectionType(WordZoomSelection type, {String? feature}) { + WordZoomSelection newSelectedType = type; + String? newSelectedFeature; + if (type != WordZoomSelection.morph) { + // if setting selectionType to non-morph activity, either set it if it's not + // already selected, or reset to it the default type + newSelectedType = + _selectionType == type ? WordZoomSelection.translation : type; + } else { + // otherwise (because there could be multiple different morph features), check + // if the feature is already selected, and if so, reset to the default type. + // if not, set the selectionType and feature + newSelectedFeature = _selectedMorphFeature == feature ? null : feature; + newSelectedType = newSelectedFeature == null + ? WordZoomSelection.translation + : WordZoomSelection.morph; } + + _selectionType = newSelectedType; + _selectedMorphFeature = newSelectedFeature; + if (mounted) setState(() {}); + } + + void _setForceShowActivity(bool showActivity) { + if (mounted) setState(() => _forceShowActivity = showActivity); + } + + /// This function should be called before overlayController.onActivityFinish to + /// prevent shouldDoActivity being set to false before _forceShowActivity is set to true. + /// This keep the completed actvity visible to the user for a short time. + void onActivityFinish({ + Duration savorTheJoyDuration = const Duration(seconds: 1), + }) { + _setForceShowActivity(true); + Future.delayed(savorTheJoyDuration, () { + _setForceShowActivity(false); + }); } Widget get _wordZoomCenterWidget { - if (widget.token.shouldDoActivity( - a: _activityType, + final showActivity = widget.token.shouldDoActivity( + a: _selectionType.activityType, feature: _selectedMorphFeature, tag: _selectedMorphFeature == null ? null : widget.token.morph[_selectedMorphFeature], ) && - canGenerateActivity[_activityType]!) { - PracticeActivityCard( + (_selectionType != WordZoomSelection.lemma || canGenerateLemmaActivity); + + if (showActivity || _forceShowActivity) { + return PracticeActivityCard( pangeaMessageEvent: widget.messageEvent, targetTokensAndActivityType: TargetTokensAndActivityType( tokens: [widget.token], - activityType: _activityType, + activityType: _selectionType.activityType, ), overlayController: widget.overlayController, morphFeature: _selectedMorphFeature, @@ -176,27 +167,26 @@ class WordZoomWidgetState extends State { ); } - if (_activityType == ActivityTypeEnum.wordMeaning) { + if (_selectionType == WordZoomSelection.translation) { return ContextualTranslationWidget( token: widget.token, - fullText: widget.messageEvent.messageDisplayText, langCode: widget.messageEvent.messageDisplayLangCode, - onPressed: () => _setActivityType(ActivityTypeEnum.wordMeaning), - definition: _definition, - setDefinition: _setDefinition, ); } - return Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [_activityAnswer], + return Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [_activityAnswer], + ), ); } Widget get _activityAnswer { - switch (_activityType) { - case ActivityTypeEnum.morphId: + switch (_selectionType) { + case WordZoomSelection.morph: if (_selectedMorphFeature == null) { return const Text("There should be a selected morph feature"); } @@ -207,75 +197,77 @@ class WordZoomWidgetState extends State { context: context, ); return Text(copy ?? morphTag); - case ActivityTypeEnum.wordMeaning: - return _definition != null - ? Text(_definition!) - : const Text("definition is null"); - case ActivityTypeEnum.lemmaId: - return _lemma != null ? Text(_lemma!) : const Text("lemma is null"); - case ActivityTypeEnum.emoji: - return _emoji != null ? Text(_emoji!) : const Text("emoji is null"); - default: + case WordZoomSelection.lemma: + return Text(widget.token.lemma.text); + case WordZoomSelection.emoji: + return widget.token.getEmoji() != null + ? Text(widget.token.getEmoji()!) + : const Text("emoji is null"); + case WordZoomSelection.translation: return const SizedBox(); } } @override Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(8.0), - child: IntrinsicWidth( - child: ConstrainedBox( - constraints: - const BoxConstraints(minHeight: AppConfig.toolbarMinHeight), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - ConstrainedBox( - constraints: - const BoxConstraints(minWidth: AppConfig.toolbarMinWidth), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - mainAxisSize: MainAxisSize.min, - children: [ - EmojiPracticeButton( - emoji: _emoji, - token: widget.token, - onPressed: () => _setActivityType( - _activityType == ActivityTypeEnum.emoji - ? ActivityTypeEnum.wordMeaning - : ActivityTypeEnum.emoji, - ), - setEmoji: _setEmoji, - ), - WordTextWithAudioButton( - text: widget.token.text.content, - ttsController: widget.tts, - eventID: widget.messageEvent.eventId, - ), - LemmaWidget( - token: widget.token, - onPressed: () => _setActivityType( - _activityType == ActivityTypeEnum.lemmaId - ? ActivityTypeEnum.wordMeaning - : ActivityTypeEnum.lemmaId, - ), - lemma: _lemma, - setLemma: _setLemma, - ), - ], - ), + return ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppConfig.toolbarMinHeight, + maxHeight: AppConfig.toolbarMaxHeight, + ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: IntrinsicWidth( + child: ConstrainedBox( + constraints: const BoxConstraints( + minHeight: AppConfig.toolbarMinHeight, ), - _wordZoomCenterWidget, - MorphologicalListWidget( - token: widget.token, - setMorphFeature: _setSelectedMorphFeature, - selectedMorphFeature: _selectedMorphFeature, - completedActivities: completedMorphActivities, + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: AppConfig.toolbarMinWidth, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + EmojiPracticeButton( + token: widget.token, + onPressed: () => + _setSelectionType(WordZoomSelection.emoji), + isSelected: _selectionType == WordZoomSelection.emoji, + ), + WordTextWithAudioButton( + text: widget.token.text.content, + ttsController: widget.tts, + eventID: widget.messageEvent.eventId, + ), + LemmaWidget( + token: widget.token, + onPressed: () => + _setSelectionType(WordZoomSelection.lemma), + isSelected: _selectionType == WordZoomSelection.lemma, + ), + ], + ), + ), + _wordZoomCenterWidget, + MorphologicalListWidget( + token: widget.token, + setMorphFeature: (feature) => _setSelectionType( + WordZoomSelection.morph, + feature: feature, + ), + selectedMorphFeature: _selectedMorphFeature, + ), + ], ), - ], + ), ), ), ),