diff --git a/lib/main.dart b/lib/main.dart index 9f5e656bd..847b012b2 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,7 +23,7 @@ void main() async { // #Pangea try { - await dotenv.load(fileName: ".env"); + await dotenv.load(fileName: ".env.local_choreo"); } catch (e) { Logs().e('Failed to load .env file', e); } diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 4e21f00ac..968d75ad3 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -4,8 +4,8 @@ import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/video_player.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_token_text_stateful.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar_selection_area.dart'; -import 'package:fluffychat/pangea/widgets/chat/overlay_message_text.dart'; import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/date_time_extension.dart'; @@ -306,13 +306,23 @@ class MessageContent extends StatelessWidget { height: 1.3, ); - if (overlayController != null && pangeaMessageEvent != null) { - return OverlayMessageText( - pangeaMessageEvent: pangeaMessageEvent!, - overlayController: overlayController!, + if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens != + null) { + return MessageTokenTextStateful( + messageAnalyticsEntry: + controller.pangeaController.getAnalytics.perMessage.get( + pangeaMessageEvent!, + false, + )!, + style: messageTextStyle, + onClick: (token) => controller.showToolbar(pangeaMessageEvent!), ); } + if (overlayController != null && pangeaMessageEvent != null) { + return overlayController!.messageTokenText; + } + if (immersionMode && pangeaMessageEvent != null) { return Flexible( child: PangeaRichText( diff --git a/lib/pangea/controllers/get_analytics_controller.dart b/lib/pangea/controllers/get_analytics_controller.dart index e9bb72fdd..2853a27e4 100644 --- a/lib/pangea/controllers/get_analytics_controller.dart +++ b/lib/pangea/controllers/get_analytics_controller.dart @@ -3,6 +3,7 @@ import 'dart:math'; import 'package:fluffychat/pangea/constants/class_default_values.dart'; import 'package:fluffychat/pangea/constants/local.key.dart'; +import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; @@ -19,16 +20,21 @@ import 'package:sentry_flutter/sentry_flutter.dart'; /// A minimized version of AnalyticsController that get the logged in user's analytics class GetAnalyticsController { late PangeaController _pangeaController; + late MessageAnalyticsController perMessage; final List _cache = []; StreamSubscription? _analyticsUpdateSubscription; StreamController analyticsStream = StreamController.broadcast(); ConstructListModel constructListModel = ConstructListModel(uses: []); - Completer? initCompleter; + Completer initCompleter = Completer(); GetAnalyticsController(PangeaController pangeaController) { _pangeaController = pangeaController; + + perMessage = MessageAnalyticsController( + this, + ); } String? get _l2Code => _pangeaController.languageController.userL2?.langCode; @@ -58,22 +64,25 @@ class GetAnalyticsController { } Future initialize() async { - if (initCompleter != null) return; - initCompleter = Completer(); - - _analyticsUpdateSubscription ??= _pangeaController - .putAnalytics.analyticsUpdateStream.stream - .listen(_onAnalyticsUpdate); + if (initCompleter.isCompleted) return; - await _pangeaController.putAnalytics.lastUpdatedCompleter.future; - await _getConstructs(); - constructListModel.updateConstructs([ - ...(_getConstructsLocal() ?? []), - ..._locallyCachedConstructs, - ]); - _updateAnalyticsStream(); - - initCompleter!.complete(); + try { + _analyticsUpdateSubscription ??= _pangeaController + .putAnalytics.analyticsUpdateStream.stream + .listen(_onAnalyticsUpdate); + + await _pangeaController.putAnalytics.lastUpdatedCompleter.future; + await _getConstructs(); + constructListModel.updateConstructs([ + ...(_getConstructsLocal() ?? []), + ..._locallyCachedConstructs, + ]); + _updateAnalyticsStream(); + } catch (err, s) { + ErrorHandler.logError(e: err, s: s); + } finally { + if (!initCompleter.isCompleted) initCompleter.complete(); + } } /// Clear all cached analytics data. @@ -81,8 +90,9 @@ class GetAnalyticsController { constructListModel.dispose(); _analyticsUpdateSubscription?.cancel(); _analyticsUpdateSubscription = null; - initCompleter = null; + initCompleter = Completer(); _cache.clear(); + // perMessage.dispose(); } Future _onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async { diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart new file mode 100644 index 000000000..d89ef3ef8 --- /dev/null +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -0,0 +1,169 @@ +import 'dart:math'; + +import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; +import 'package:flutter/foundation.dart'; + +/// Picks which tokens to do activities on and what types of activities to do +/// Caches result so that we don't have to recompute it +/// Most importantly, we can't do this in the state of a message widget because the state is disposed of and recreated +/// If we decided that the first token should have a hidden word listening, we need to remember that +/// Otherwise, the user might leave the chat, return, and see a different word hidden +class MessageAnalyticsEntry { + final DateTime createdAt = DateTime.now(); + + late List tokensWithXp; + + final PangeaMessageEvent pmEvent; + + // + bool isFirstTimeComputing = true; + + TokenWithXP? nextActivityToken; + ActivityTypeEnum? nextActivityType; + + MessageAnalyticsEntry(this.pmEvent) { + debugPrint('making MessageAnalyticsEntry: ${pmEvent.messageDisplayText}'); + if (pmEvent.messageDisplayRepresentation?.tokens == null) { + throw Exception('No tokens in message in MessageAnalyticsEntry'); + } + tokensWithXp = pmEvent.messageDisplayRepresentation!.tokens! + .map((token) => TokenWithXP(token: token)) + .toList(); + + computeTargetTypesForMessage(); + } + + List get tokensThatCanBeHeard => + tokensWithXp.where((t) => t.token.canBeHeard).toList(); + + // compute target tokens within async wrapper that adds a 250ms delay + // to avoid blocking the UI thread + Future computeTargetTypesForMessageAsync() async { + await Future.delayed(const Duration(milliseconds: 250)); + computeTargetTypesForMessage(); + } + + void computeTargetTypesForMessage() { + // reset + nextActivityToken = null; + nextActivityType = null; + + // compute target types for each token + for (final token in tokensWithXp) { + token.targetTypes = []; + + if (!token.token.lemma.saveVocab) { + continue; + } + + if (token.daysSinceLastUse < 1) { + continue; + } + + if (token.eligibleForActivity(ActivityTypeEnum.wordMeaning) && + !token.didActivity(ActivityTypeEnum.wordMeaning)) { + token.targetTypes.add(ActivityTypeEnum.wordMeaning); + } + + if (token.eligibleForActivity(ActivityTypeEnum.wordFocusListening) && + !token.didActivity(ActivityTypeEnum.wordFocusListening) && + tokensThatCanBeHeard.length > 3) { + token.targetTypes.add(ActivityTypeEnum.wordFocusListening); + } + + if (token.eligibleForActivity(ActivityTypeEnum.hiddenWordListening) && + isFirstTimeComputing && + !token.didActivity(ActivityTypeEnum.hiddenWordListening) && + !pmEvent.ownMessage) { + token.targetTypes.add(ActivityTypeEnum.hiddenWordListening); + } + } + + // from the tokens with hiddenWordListening in targetTypes, pick one at random + final List withListening = tokensWithXp + .asMap() + .entries + .where( + (entry) => entry.value.targetTypes + .contains(ActivityTypeEnum.hiddenWordListening), + ) + .map((entry) => entry.key) + .toList(); + // randomly pick one entry in the list + if (withListening.isNotEmpty) { + final int randomIndex = + withListening[Random().nextInt(withListening.length)]; + + nextActivityToken = tokensWithXp[randomIndex]; + nextActivityType = ActivityTypeEnum.hiddenWordListening; + + // remove from all other tokens + for (int i = 0; i < tokensWithXp.length; i++) { + if (i != randomIndex) { + tokensWithXp[i] + .targetTypes + .remove(ActivityTypeEnum.hiddenWordListening); + } + } + } + + // if we didn't find any hiddenWordListening, + // pick the first token that has a target type + nextActivityToken ??= + tokensWithXp.where((t) => t.targetTypes.isNotEmpty).firstOrNull; + nextActivityType ??= nextActivityToken?.targetTypes.firstOrNull; + + isFirstTimeComputing = false; + } + + bool get shouldHideToken => tokensWithXp.any( + (token) => + token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening), + ); +} + +/// computes TokenWithXP for given a pangeaMessageEvent and caches the result, according to the full text of the message +/// listens for analytics updates and updates the cache accordingly +class MessageAnalyticsController { + final GetAnalyticsController getAnalytics; + final Map _cache = {}; + + MessageAnalyticsController(this.getAnalytics); + + void dispose() { + _cache.clear(); + } + + // if over 50, remove oldest 5 entries by createdAt + void clean() { + if (_cache.length > 50) { + final sortedEntries = _cache.entries.toList() + ..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt)); + for (var i = 0; i < 5; i++) { + _cache.remove(sortedEntries[i].key); + } + } + } + + MessageAnalyticsEntry? get( + PangeaMessageEvent pmEvent, + bool refresh, + ) { + if (pmEvent.messageDisplayRepresentation?.tokens == null) { + return null; + } + + if (_cache.containsKey(pmEvent.messageDisplayText) && !refresh) { + return _cache[pmEvent.messageDisplayText]; + } + + _cache[pmEvent.messageDisplayText] = MessageAnalyticsEntry(pmEvent); + + clean(); + + return _cache[pmEvent.messageDisplayText]; + } +} diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index c7238c9cc..c5b2c0c4a 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -90,6 +90,13 @@ class PracticeGenerationController { final response = MessageActivityResponse.fromJson(json); + // workaround for the server not returning the tgtConstructs + // if (response.activity != null && + // response.activity!.tgtConstructs.isEmpty) { + // response.activity?.tgtConstructs.addAll( + // requestModel.clientTokenRequest.constructIDs, + // ); + // } return response; } else { debugger(when: kDebugMode); @@ -105,6 +112,8 @@ class PracticeGenerationController { ) async { final int cacheKey = req.hashCode; + // debugger(when: kDebugMode); + if (_cache.containsKey(cacheKey)) { return _cache[cacheKey]!.practiceActivity; } diff --git a/lib/pangea/controllers/put_analytics_controller.dart b/lib/pangea/controllers/put_analytics_controller.dart index 26e0507a4..8ced8b76b 100644 --- a/lib/pangea/controllers/put_analytics_controller.dart +++ b/lib/pangea/controllers/put_analytics_controller.dart @@ -174,7 +174,7 @@ class PutAnalyticsController extends BaseController { (token) => OneConstructUse( useType: useType, lemma: token.lemma.text, - form: token.lemma.form, + form: token.text.content, constructType: ConstructTypeEnum.vocab, metadata: metadata, category: token.pos, diff --git a/lib/pangea/enum/activity_type_enum.dart b/lib/pangea/enum/activity_type_enum.dart index 66bfb3e61..fc11c2e0d 100644 --- a/lib/pangea/enum/activity_type_enum.dart +++ b/lib/pangea/enum/activity_type_enum.dart @@ -1,12 +1,61 @@ -enum ActivityTypeEnum { multipleChoice, wordFocusListening } +import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; + +enum ActivityTypeEnum { wordMeaning, wordFocusListening, hiddenWordListening } extension ActivityTypeExtension on ActivityTypeEnum { String get string { switch (this) { - case ActivityTypeEnum.multipleChoice: - return 'multiple_choice'; + case ActivityTypeEnum.wordMeaning: + return 'word_meaning'; case ActivityTypeEnum.wordFocusListening: return 'word_focus_listening'; + case ActivityTypeEnum.hiddenWordListening: + return 'hidden_word_listening'; + } + } + + ActivityTypeEnum fromString(String value) { + final split = value.split('.').last; + switch (split) { + // used to be called multiple_choice, but we changed it to word_meaning + // as we now have multiple types of multiple choice activities + // old data will still have multiple_choice so we need to handle that + case 'multiple_choice': + case 'multipleChoice': + case 'word_meaning': + case 'wordMeaning': + return ActivityTypeEnum.wordMeaning; + case 'word_focus_listening': + case 'wordFocusListening': + return ActivityTypeEnum.wordFocusListening; + case 'hidden_word_listening': + case 'hiddenWordListening': + return ActivityTypeEnum.hiddenWordListening; + default: + throw Exception('Unknown activity type: $split'); + } + } + + List get associatedUseTypes { + switch (this) { + case ActivityTypeEnum.wordMeaning: + return [ + ConstructUseTypeEnum.corPA, + ConstructUseTypeEnum.incPA, + ConstructUseTypeEnum.ignPA + ]; + case ActivityTypeEnum.wordFocusListening: + return [ + ConstructUseTypeEnum.corWL, + ConstructUseTypeEnum.incWL, + ConstructUseTypeEnum.ignWL + ]; + case ActivityTypeEnum.hiddenWordListening: + return [ + ConstructUseTypeEnum.corHWL, + ConstructUseTypeEnum.incHWL, + ConstructUseTypeEnum.ignHWL + ]; } } } diff --git a/lib/pangea/enum/construct_use_type_enum.dart b/lib/pangea/enum/construct_use_type_enum.dart index cb6b2a257..ee3871f92 100644 --- a/lib/pangea/enum/construct_use_type_enum.dart +++ b/lib/pangea/enum/construct_use_type_enum.dart @@ -42,12 +42,21 @@ enum ConstructUseTypeEnum { /// was target lemma in word-focus listening activity and correctly selected corWL, - /// form of lemma was read-aloud in word-focus listening activity and incorrectly selected + /// a form of lemma was read-aloud in word-focus listening activity and incorrectly selected incWL, - /// form of lemma was read-aloud in word-focus listening activity and correctly ignored + /// a form of the lemma was read-aloud in word-focus listening activity and correctly ignored ignWL, + /// correctly chose a form of the lemma in a hidden word listening activity + corHWL, + + /// incorrectly chose a form of the lemma in a hidden word listening activity + incHWL, + + /// ignored a form of the lemma in a hidden word listening activity + ignHWL, + /// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client nan } @@ -71,12 +80,15 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.ignPA: case ConstructUseTypeEnum.ignWL: case ConstructUseTypeEnum.incWL: + case ConstructUseTypeEnum.incHWL: + case ConstructUseTypeEnum.ignHWL: return Icons.close; case ConstructUseTypeEnum.ga: case ConstructUseTypeEnum.corIGC: case ConstructUseTypeEnum.corPA: case ConstructUseTypeEnum.corWL: + case ConstructUseTypeEnum.corHWL: return Icons.check; case ConstructUseTypeEnum.unk: @@ -98,6 +110,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.wa: case ConstructUseTypeEnum.corWL: + case ConstructUseTypeEnum.corHWL: return 3; case ConstructUseTypeEnum.corIGC: @@ -110,6 +123,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.ignIGC: case ConstructUseTypeEnum.ignPA: case ConstructUseTypeEnum.ignWL: + case ConstructUseTypeEnum.ignHWL: case ConstructUseTypeEnum.unk: case ConstructUseTypeEnum.nan: return 0; @@ -123,6 +137,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum { case ConstructUseTypeEnum.incPA: case ConstructUseTypeEnum.incWL: + case ConstructUseTypeEnum.incHWL: return -3; } } diff --git a/lib/pangea/models/analytics/construct_list_model.dart b/lib/pangea/models/analytics/construct_list_model.dart index 12bd7fbe3..bc82855f6 100644 --- a/lib/pangea/models/analytics/construct_list_model.dart +++ b/lib/pangea/models/analytics/construct_list_model.dart @@ -1,7 +1,7 @@ import 'dart:math'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; -import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -65,6 +65,7 @@ class ConstructListModel { category: use.category, ); currentUses.uses.add(use); + currentUses.setLastUsed(use.timeStamp); _constructMap[use.identifier.string] = currentUses; } } @@ -149,38 +150,3 @@ class ConstructListModel { ); } } - -/// One lemma and a list of construct uses for that lemma -class ConstructUses { - final List uses; - final ConstructTypeEnum constructType; - final String lemma; - final String? _category; - - ConstructUses({ - required this.uses, - required this.constructType, - required this.lemma, - required category, - }) : _category = category; - - // Total points for all uses of this lemma - int get points { - return uses.fold( - 0, - (total, use) => total + use.useType.pointValue, - ); - } - - DateTime? _lastUsed; - DateTime? get lastUsed { - if (_lastUsed != null) return _lastUsed; - final lastUse = uses.fold(null, (DateTime? last, use) { - if (last == null) return use.timeStamp; - return use.timeStamp.isAfter(last) ? use.timeStamp : last; - }); - return _lastUsed = lastUse; - } - - String get category => _category ?? "Other"; -} diff --git a/lib/pangea/models/analytics/construct_use_model.dart b/lib/pangea/models/analytics/construct_use_model.dart new file mode 100644 index 000000000..09195ecba --- /dev/null +++ b/lib/pangea/models/analytics/construct_use_model.dart @@ -0,0 +1,61 @@ +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; + +/// One lemma and a list of construct uses for that lemma +class ConstructUses { + final List uses; + final ConstructTypeEnum constructType; + final String lemma; + final String? _category; + DateTime? _lastUsed; + + ConstructUses({ + required this.uses, + required this.constructType, + required this.lemma, + required category, + }) : _category = category; + + // Total points for all uses of this lemma + int get points { + return uses.fold( + 0, + (total, use) => total + use.useType.pointValue, + ); + } + + DateTime? get lastUsed { + if (_lastUsed != null) return _lastUsed; + final lastUse = uses.fold(null, (DateTime? last, use) { + if (last == null) return use.timeStamp; + return use.timeStamp.isAfter(last) ? use.timeStamp : last; + }); + return _lastUsed = lastUse; + } + + void setLastUsed(DateTime time) { + _lastUsed = time; + } + + String get category => _category ?? "Other"; + + ConstructIdentifier get id => ConstructIdentifier( + lemma: lemma, + type: constructType, + category: category, + ); + + Map toJson() { + final json = { + 'construct_id': id.toJson(), + 'xp': points, + 'last_used': lastUsed?.toIso8601String(), + + /// NOTE - sent to server as just the useTypes + 'uses': uses.map((e) => e.useType.string).toList(), + }; + return json; + } +} diff --git a/lib/pangea/models/analytics/constructs_model.dart b/lib/pangea/models/analytics/constructs_model.dart index 22fee9ee5..3fd2feaa0 100644 --- a/lib/pangea/models/analytics/constructs_model.dart +++ b/lib/pangea/models/analytics/constructs_model.dart @@ -145,7 +145,8 @@ class OneConstructUse { for (final String category in morphCategoriesAndLabels.keys) { if (morphCategoriesAndLabels[category]!.contains(morphLemma)) { debugPrint( - "found missing construct category for $morphLemma: $category"); + "found missing construct category for $morphLemma: $category", + ); return category; } } diff --git a/lib/pangea/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index bd47ed143..8d292dfd1 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -4,8 +4,6 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:flutter/foundation.dart'; import '../constants/model_keys.dart'; @@ -13,6 +11,9 @@ import 'lemma.dart'; class PangeaToken { PangeaTokenText text; + + //TODO - make this a string and move save_vocab to this class + // clients have been able to handle null lemmas for 12 months so this is safe Lemma lemma; /// [pos] ex "VERB" - part of speech of the token @@ -120,37 +121,21 @@ class PangeaToken { /// alias for the end of the token ie offset + length int get end => text.offset + text.length; - /// create an empty tokenWithXP object - TokenWithXP get emptyTokenWithXP { - final List constructs = []; - - constructs.add( - ConstructWithXP( - id: ConstructIdentifier( - lemma: lemma.text, - type: ConstructTypeEnum.vocab, - category: pos, - ), - ), - ); - - for (final morph in morph.entries) { - constructs.add( - ConstructWithXP( - id: ConstructIdentifier( - lemma: morph.value, - type: ConstructTypeEnum.morph, - category: morph.key, - ), - ), - ); - } - - return TokenWithXP( - token: this, - constructs: constructs, - ); - } + bool get isContentWord => ["NOUN", "VERB", "ADJ", "ADV"].contains(pos); + + bool get canBeHeard => [ + "ADJ", + "ADV", + "AUX", + "DET", + "INTJ", + "NOUN", + "NUM", + "PRON", + "PROPN", + "SCONJ", + "VERB", + ].contains(pos); /// Given a [type] and [metadata], returns a [OneConstructUse] for this lemma OneConstructUse toVocabUse( @@ -160,7 +145,7 @@ class PangeaToken { return OneConstructUse( useType: type, lemma: lemma.text, - form: lemma.form, + form: text.content, constructType: ConstructTypeEnum.vocab, metadata: metadata, category: pos, diff --git a/lib/pangea/models/practice_activities.dart/message_activity_request.dart b/lib/pangea/models/practice_activities.dart/message_activity_request.dart index edb702cda..159e37870 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -1,83 +1,59 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; -import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; +import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; - -class ConstructWithXP { - final ConstructIdentifier id; - int xp; - DateTime? lastUsed; - List condensedConstructUses; - - ConstructWithXP({ - required this.id, - this.xp = 0, - this.lastUsed, - this.condensedConstructUses = const [], - }); - - factory ConstructWithXP.fromJson(Map json) { - return ConstructWithXP( - id: ConstructIdentifier.fromJson( - json['construct_id'] as Map, - ), - xp: json['xp'] as int, - lastUsed: json['last_used'] != null - ? DateTime.parse(json['last_used'] as String) - : null, - condensedConstructUses: (json['uses'] as List).map((e) { - return ConstructUseTypeUtil.fromString(e); - }).toList(), - ); - } - - Map toJson() { - final json = { - 'construct_id': id.toJson(), - 'xp': xp, - 'last_used': lastUsed?.toIso8601String(), - 'uses': condensedConstructUses.map((e) => e.string).toList(), - }; - return json; - } -} +import 'package:fluffychat/widgets/matrix.dart'; class TokenWithXP { final PangeaToken token; - final List constructs; - - DateTime? get lastUsed { - return constructs.fold( - null, - (previousValue, element) { - if (previousValue == null) return element.lastUsed; - if (element.lastUsed == null) return previousValue; - return element.lastUsed!.isAfter(previousValue) - ? element.lastUsed - : previousValue; - }, - ); + late List targetTypes; + + TokenWithXP({ + required this.token, + }) { + targetTypes = []; } - int get xp { - return constructs.fold( - 0, - (previousValue, element) => previousValue + element.xp, + List get _constructIDs { + final List ids = []; + ids.add( + ConstructIdentifier( + lemma: token.lemma.text, + type: ConstructTypeEnum.vocab, + category: token.pos, + ), ); + for (final morph in token.morph.entries) { + ids.add( + ConstructIdentifier( + lemma: morph.value, + type: ConstructTypeEnum.morph, + category: morph.key, + ), + ); + } + return ids; } - TokenWithXP({ - required this.token, - required this.constructs, - }); + List get constructs => _constructIDs + .map( + (id) => + MatrixState.pangeaController.getAnalytics.constructListModel + .getConstructUses(id) ?? + ConstructUses( + lemma: id.lemma, + constructType: id.type, + category: id.category, + uses: [], + ), + ) + .toList(); factory TokenWithXP.fromJson(Map json) { return TokenWithXP( token: PangeaToken.fromJson(json['token'] as Map), - constructs: (json['constructs'] as List) - .map((e) => ConstructWithXP.fromJson(e as Map)) - .toList(), ); } @@ -85,21 +61,96 @@ class TokenWithXP { return { 'token': token.toJson(), 'constructs_with_xp': constructs.map((e) => e.toJson()).toList(), + 'target_types': targetTypes.map((e) => e.string).toList(), }; } + bool eligibleForActivity(ActivityTypeEnum a) { + switch (a) { + case ActivityTypeEnum.wordMeaning: + return token.isContentWord; + case ActivityTypeEnum.wordFocusListening: + case ActivityTypeEnum.hiddenWordListening: + return token.canBeHeard; + } + } + + bool didActivity(ActivityTypeEnum a) { + switch (a) { + case ActivityTypeEnum.wordMeaning: + return vocabConstruct.uses + .map((u) => u.useType) + .any((u) => a.associatedUseTypes.contains(u)); + case ActivityTypeEnum.wordFocusListening: + return vocabConstruct.uses + // TODO - double-check that form is going to be available here + // .where((u) => + // u.form?.toLowerCase() == token.text.content.toLowerCase(),) + .map((u) => u.useType) + .any((u) => a.associatedUseTypes.contains(u)); + case ActivityTypeEnum.hiddenWordListening: + return vocabConstruct.uses + // TODO - double-check that form is going to be available here + // .where((u) => + // u.form?.toLowerCase() == token.text.content.toLowerCase(),) + .map((u) => u.useType) + .any((u) => a.associatedUseTypes.contains(u)); + } + } + + ConstructUses get vocabConstruct { + final vocab = constructs.firstWhereOrNull( + (element) => element.id.type == ConstructTypeEnum.vocab, + ); + if (vocab == null) { + return ConstructUses( + lemma: token.lemma.text, + constructType: ConstructTypeEnum.vocab, + category: token.pos, + uses: [], + ); + } + return vocab; + } + + int get xp { + return constructs.fold( + 0, + (previousValue, element) => previousValue + element.points, + ); + } + + /// + DateTime? get lastUsed => constructs.fold( + null, + (previousValue, element) { + if (previousValue == null) return element.lastUsed; + if (element.lastUsed == null) return previousValue; + return element.lastUsed!.isAfter(previousValue) + ? element.lastUsed + : previousValue; + }, + ); + + /// daysSinceLastUse + int get daysSinceLastUse { + if (lastUsed == null) return 1000; + return DateTime.now().difference(lastUsed!).inDays; + } + + //override operator == and hashCode + // check that the list of constructs are the same @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is TokenWithXP && - other.token.text == token.text && - other.lastUsed == lastUsed; + const ListEquality().equals(other.constructs, constructs); } @override int get hashCode { - return token.text.hashCode ^ lastUsed.hashCode; + return const ListEquality().hash(constructs); } } @@ -197,6 +248,10 @@ class MessageActivityRequest { final List clientCompatibleActivities; + final ActivityTypeEnum clientTypeRequest; + + final TokenWithXP clientTokenRequest; + MessageActivityRequest({ required this.userL1, required this.userL2, @@ -205,52 +260,10 @@ class MessageActivityRequest { required this.messageId, required this.existingActivities, required this.activityQualityFeedback, - clientCompatibleActivities, - }) : clientCompatibleActivities = - clientCompatibleActivities ?? ActivityTypeEnum.values; - - factory MessageActivityRequest.fromJson(Map json) { - final clientCompatibleActivitiesEntry = - json['client_version_compatible_activity_types']; - List? clientCompatibleActivities; - if (clientCompatibleActivitiesEntry != null && - clientCompatibleActivitiesEntry is List) { - clientCompatibleActivities = clientCompatibleActivitiesEntry - .map( - (e) => ActivityTypeEnum.values.firstWhereOrNull( - (element) => - element.string == e as String || - element.string.split('.').last == e, - ), - ) - .where((entry) => entry != null) - .cast() - .toList(); - } - return MessageActivityRequest( - userL1: json['user_l1'] as String, - userL2: json['user_l2'] as String, - messageText: json['message_text'] as String, - tokensWithXP: (json['tokens_with_xp'] as List) - .map((e) => TokenWithXP.fromJson(e as Map)) - .toList(), - messageId: json['message_id'] as String, - existingActivities: (json['existing_activities'] as List) - .map( - (e) => ExistingActivityMetaData.fromJson(e as Map), - ) - .toList(), - activityQualityFeedback: json['activity_quality_feedback'] != null - ? ActivityQualityFeedback.fromJson( - json['activity_quality_feedback'] as Map, - ) - : null, - clientCompatibleActivities: clientCompatibleActivities != null && - clientCompatibleActivities.isNotEmpty - ? clientCompatibleActivities - : ActivityTypeEnum.values, - ); - } + required this.clientCompatibleActivities, + required this.clientTokenRequest, + required this.clientTypeRequest, + }); Map toJson() { return { @@ -267,6 +280,8 @@ class MessageActivityRequest { // this for backwards compatibility with old clients 'client_version_compatible_activity_types': clientCompatibleActivities.map((e) => e.string).toList(), + 'client_type_request': clientTypeRequest.string, + 'client_token_request': clientTokenRequest.toJson(), }; } diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart index d0b7bdb73..927f6b5a2 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -224,13 +224,8 @@ class PracticeActivityModel { .toList(), langCode: json['lang_code'] as String, msgId: json['msg_id'] as String, - activityType: json['activity_type'] == "multipleChoice" - ? ActivityTypeEnum.multipleChoice - : ActivityTypeEnum.values.firstWhere( - (e) => - e.string == json['activity_type'] as String || - e.string.split('.').last == json['activity_type'] as String, - ), + activityType: + ActivityTypeEnum.wordMeaning.fromString(json['activity_type']), content: ActivityContent.fromJson(contentMap), ); } 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 74f2d0d64..d60ee3575 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 @@ -4,6 +4,7 @@ // finding the answer import 'dart:developer'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; @@ -131,8 +132,22 @@ class ActivityRecordResponse { }); //TODO - differentiate into different activity types - ConstructUseTypeEnum get useType => - score > 0 ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA; + ConstructUseTypeEnum useType(ActivityTypeEnum aType) { + switch (aType) { + case ActivityTypeEnum.wordMeaning: + return score > 0 + ? ConstructUseTypeEnum.corPA + : ConstructUseTypeEnum.incPA; + case ActivityTypeEnum.wordFocusListening: + return score > 0 + ? ConstructUseTypeEnum.corWL + : ConstructUseTypeEnum.incWL; + case ActivityTypeEnum.hiddenWordListening: + return score > 0 + ? ConstructUseTypeEnum.corHWL + : ConstructUseTypeEnum.incHWL; + } + } // for each target construct create a OneConstructUse object List toUses( @@ -146,7 +161,7 @@ class ActivityRecordResponse { // TODO - add form to practiceActivity target_construct data somehow form: null, constructType: construct.type, - useType: useType, + useType: useType(practiceActivity.activityType), metadata: metadata, category: construct.category, ), diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index c6d9f0e7c..be0101bf7 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -12,13 +12,13 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_token_text.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart'; import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart'; import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity/target_tokens_controller.dart'; import 'package:fluffychat/widgets/avatar.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; @@ -75,8 +75,8 @@ class MessageOverlayController extends State bool isPlayingAudio = false; bool get showToolbarButtons => !widget._pangeaMessageEvent.isAudioMessage; - final TargetTokensController targetTokensController = - TargetTokensController(); + + List? tokens; @override void initState() { @@ -87,6 +87,8 @@ class MessageOverlayController extends State const Duration(milliseconds: AppConfig.overlayAnimationDuration), ); + _getTokens(); + activitiesLeftToComplete = activitiesLeftToComplete - widget._pangeaMessageEvent.numberOfActivitiesCompleted; @@ -114,6 +116,39 @@ class MessageOverlayController extends State setInitialToolbarMode(); } + MessageTokenText get messageTokenText => MessageTokenText( + ownMessage: pangeaMessageEvent.ownMessage, + fullText: pangeaMessageEvent.messageDisplayText, + tokensWithDisplay: tokens + ?.map( + (token) => TokenWithDisplayInstructions( + token: token, + highlight: isTokenSelected(token), + //NOTE: we actually do want the controller to be aware of which + // tokens are currently being involved in activities and adjust here + hideContent: false, + ), + ) + .toList(), + onClick: onClickOverlayMessageToken, + ); + + Future _getTokens() async { + tokens = pangeaMessageEvent.originalSent?.tokens; + + if (pangeaMessageEvent.originalSent != null && tokens == null) { + pangeaMessageEvent.originalSent! + .tokensGlobal( + pangeaMessageEvent.senderId, + pangeaMessageEvent.originServerTs, + ) + .then((tokens) { + // this isn't currently working because originalSent's _event is null + setState(() => this.tokens = tokens); + }); + } + } + /// We need to check if the setState call is safe to call immediately /// Kept getting the error: setState() or markNeedsBuild() called during build. /// This is a workaround to prevent that error @@ -493,7 +528,6 @@ class MessageOverlayController extends State pangeaMessageEvent: widget._pangeaMessageEvent, overLayController: this, ttsController: tts, - targetTokensController: targetTokensController, ), const SizedBox(height: 8), SizedBox( diff --git a/lib/pangea/widgets/chat/message_token_text.dart b/lib/pangea/widgets/chat/message_token_text.dart new file mode 100644 index 000000000..96cf3ae13 --- /dev/null +++ b/lib/pangea/widgets/chat/message_token_text.dart @@ -0,0 +1,138 @@ +import 'package:fluffychat/config/app_config.dart'; +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +class MessageTokenText extends StatelessWidget { + final PangeaController pangeaController = MatrixState.pangeaController; + + final bool ownMessage; + + /// this must match the tokens or we've got problems + final String fullText; + + /// this must match the fullText or we've got problems + final List? tokensWithDisplay; + final void Function(PangeaToken)? onClick; + + MessageTokenText({ + super.key, + required this.ownMessage, + required this.fullText, + required this.tokensWithDisplay, + required this.onClick, + }); + + @override + Widget build(BuildContext context) { + final style = TextStyle( + color: ownMessage + ? Theme.of(context).colorScheme.onPrimary + : Theme.of(context).colorScheme.onSurface, + height: 1.3, + fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor, + ); + + if (tokensWithDisplay == null || tokensWithDisplay!.isEmpty) { + return Text( + fullText, + style: style, + ); + } + + // Convert the entire message into a list of characters + final Characters messageCharacters = fullText.characters; + + // When building token positions, use grapheme cluster indices + final List tokenPositions = []; + int globalIndex = 0; + + for (int i = 0; i < tokensWithDisplay!.length; i++) { + final tokenWithDisplay = tokensWithDisplay![i]; + final start = tokenWithDisplay.token.start; + final end = tokenWithDisplay.token.end; + + // Calculate the number of grapheme clusters up to the start and end positions + final int startIndex = messageCharacters.take(start).length; + final int endIndex = messageCharacters.take(end).length; + + if (globalIndex < startIndex) { + tokenPositions.add(TokenPosition(start: globalIndex, end: startIndex)); + } + + tokenPositions.add( + TokenPosition( + start: startIndex, + end: endIndex, + tokenIndex: i, + token: tokenWithDisplay, + ), + ); + globalIndex = endIndex; + } + + //TODO - take out of build function of every message + return RichText( + text: TextSpan( + children: tokenPositions.map((tokenPosition) { + final substring = messageCharacters + .skip(tokenPosition.start) + .take(tokenPosition.end - tokenPosition.start) + .toString(); + + if (tokenPosition.token != null) { + return TextSpan( + recognizer: TapGestureRecognizer() + ..onTap = () => onClick != null + ? onClick!(tokenPosition.token!.token) + : null, + text: !tokenPosition.token!.hideContent ? substring : '_____', + style: style.merge( + TextStyle( + backgroundColor: tokenPosition.token!.highlight + ? Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.4) + : Colors.white.withOpacity(0.4) + : Colors.transparent, + ), + ), + ); + } else { + return TextSpan( + text: substring, + style: style, + ); + } + }).toList(), + ), + ); + } +} + +class TokenWithDisplayInstructions { + final PangeaToken token; + final bool highlight; + final bool hideContent; + + TokenWithDisplayInstructions({ + required this.token, + required this.highlight, + required this.hideContent, + }); +} + +class TokenPosition { + final int start; + final int end; + final TokenWithDisplayInstructions? token; + final int tokenIndex; + + const TokenPosition({ + required this.start, + required this.end, + this.token, + this.tokenIndex = -1, + }); +} diff --git a/lib/pangea/widgets/chat/message_token_text_stateful.dart b/lib/pangea/widgets/chat/message_token_text_stateful.dart new file mode 100644 index 000000000..f5e40062f --- /dev/null +++ b/lib/pangea/widgets/chat/message_token_text_stateful.dart @@ -0,0 +1,125 @@ +import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; +import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; +import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/widgets/matrix.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; + +/// Question - does this need to be stateful or does this work? +/// Need to test. +class MessageTokenTextStateful extends StatelessWidget { + final PangeaController pangeaController = MatrixState.pangeaController; + + final MessageAnalyticsEntry messageAnalyticsEntry; + + final TextStyle style; + + final void Function(PangeaToken)? onClick; + + bool get ownMessage => messageAnalyticsEntry.pmEvent.ownMessage; + + MessageTokenTextStateful({ + super.key, + required this.messageAnalyticsEntry, + required this.style, + required this.onClick, + }); + + PangeaMessageEvent get pangeaMessageEvent => messageAnalyticsEntry.pmEvent; + + @override + Widget build(BuildContext context) { + // Convert the entire message into a list of characters + final Characters messageCharacters = + pangeaMessageEvent.messageDisplayText.characters; + + // When building token positions, use grapheme cluster indices + final List tokenPositions = []; + int globalIndex = 0; + + for (final token in messageAnalyticsEntry.tokensWithXp) { + final start = token.token.start; + final end = token.token.end; + + // Calculate the number of grapheme clusters up to the start and end positions + final int startIndex = messageCharacters.take(start).length; + final int endIndex = messageCharacters.take(end).length; + + if (globalIndex < startIndex) { + tokenPositions.add( + TokenPosition( + start: globalIndex, + end: startIndex, + hideContent: false, + highlight: false, + ), + ); + } + + tokenPositions.add( + TokenPosition( + start: startIndex, + end: endIndex, + token: token.token, + hideContent: + token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening), + highlight: false, + ), + ); + globalIndex = endIndex; + } + + return RichText( + text: TextSpan( + children: tokenPositions.map((tokenPosition) { + final substring = messageCharacters + .skip(tokenPosition.start) + .take(tokenPosition.end - tokenPosition.start) + .toString(); + + if (tokenPosition.token != null) { + return TextSpan( + recognizer: TapGestureRecognizer() + ..onTap = () => onClick != null && tokenPosition.token != null + ? onClick!(tokenPosition.token!) + : null, + text: !tokenPosition.hideContent ? substring : '_____', + style: style.merge( + TextStyle( + backgroundColor: tokenPosition.highlight + ? Theme.of(context).brightness == Brightness.light + ? Colors.black.withOpacity(0.4) + : Colors.white.withOpacity(0.4) + : Colors.transparent, + ), + ), + ); + } else { + return TextSpan( + text: substring, + style: style, + ); + } + }).toList(), + ), + ); + } +} + +class TokenPosition { + final int start; + final int end; + final bool highlight; + final bool hideContent; + final PangeaToken? token; + + const TokenPosition({ + required this.start, + required this.end, + required this.hideContent, + required this.highlight, + this.token, + }); +} diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index be1c2b5ce..42ed552f7 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -16,7 +16,6 @@ import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart'; import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart'; import 'package:fluffychat/pangea/widgets/message_display_card.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity/target_tokens_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -28,14 +27,12 @@ class MessageToolbar extends StatelessWidget { final PangeaMessageEvent pangeaMessageEvent; final MessageOverlayController overLayController; final TtsController ttsController; - final TargetTokensController targetTokensController; const MessageToolbar({ super.key, required this.pangeaMessageEvent, required this.overLayController, required this.ttsController, - required this.targetTokensController, }); Widget toolbarContent(BuildContext context) { @@ -73,7 +70,9 @@ class MessageToolbar extends StatelessWidget { case MessageMode.definition: if (!overLayController.isSelection) { return FutureBuilder( - future: targetTokensController.targetTokens(pangeaMessageEvent), + //TODO - convert this to synchronous if possible + future: Future.value( + pangeaMessageEvent.messageDisplayRepresentation?.tokens), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const ToolbarContentLoadingIndicator(); @@ -131,7 +130,6 @@ class MessageToolbar extends StatelessWidget { pangeaMessageEvent: pangeaMessageEvent, overlayController: overLayController, ttsController: ttsController, - targetTokensController: targetTokensController, ); default: debugger(when: kDebugMode); diff --git a/lib/pangea/widgets/chat/overlay_message.dart b/lib/pangea/widgets/chat/overlay_message.dart index db6ba8424..f754a6bcf 100644 --- a/lib/pangea/widgets/chat/overlay_message.dart +++ b/lib/pangea/widgets/chat/overlay_message.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; import 'package:matrix/matrix.dart'; +// @ggurdin be great to explain the need/function of a widget like this class OverlayMessage extends StatelessWidget { final PangeaMessageEvent pangeaMessageEvent; final MessageOverlayController overlayController; diff --git a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart index 764475a2c..8e78bdffc 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_popup.dart @@ -2,6 +2,7 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/material.dart'; diff --git a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart index 1d89ecafd..13440c695 100644 --- a/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart +++ b/lib/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart @@ -1,7 +1,7 @@ import 'package:fluffychat/config/app_config.dart'; import 'package:fluffychat/pangea/enum/construct_type_enum.dart'; import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart'; -import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; +import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart'; import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart'; import 'package:flutter/material.dart'; diff --git a/lib/pangea/widgets/practice_activity/generate_practice_activity_button.dart b/lib/pangea/widgets/practice_activity/generate_practice_activity_button.dart deleted file mode 100644 index f2e20dcd8..000000000 --- a/lib/pangea/widgets/practice_activity/generate_practice_activity_button.dart +++ /dev/null @@ -1,62 +0,0 @@ -import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart'; -import 'package:fluffychat/widgets/matrix.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_gen/gen_l10n/l10n.dart'; - -class GeneratePracticeActivityButton extends StatelessWidget { - final PangeaMessageEvent pangeaMessageEvent; - final Function(PracticeActivityEvent?) onActivityGenerated; - - const GeneratePracticeActivityButton({ - super.key, - required this.pangeaMessageEvent, - required this.onActivityGenerated, - }); - - //TODO - probably disable the generation of activities for specific messages - @override - Widget build(BuildContext context) { - return ElevatedButton( - onPressed: () async { - final String? l2Code = MatrixState.pangeaController.languageController - .activeL1Model() - ?.langCode; - - if (l2Code == null) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text(L10n.of(context)!.noLanguagesSet), - ), - ); - return; - } - - throw UnimplementedError(); - - // final PracticeActivityEvent? practiceActivityEvent = await MatrixState - // .pangeaController.practiceGenerationController - // .getPracticeActivity( - // MessageActivityRequest( - // candidateMessages: [ - // CandidateMessage( - // msgId: pangeaMessageEvent.eventId, - // roomId: pangeaMessageEvent.room.id, - // text: - // pangeaMessageEvent.representationByLanguage(l2Code)?.text ?? - // pangeaMessageEvent.body, - // ), - // ], - // userIds: pangeaMessageEvent.room.client.userID != null - // ? [pangeaMessageEvent.room.client.userID!] - // : null, - // ), - // pangeaMessageEvent, - // ); - - // onActivityGenerated(practiceActivityEvent); - }, - child: Text(L10n.of(context)!.practice), - ); - } -} diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart index f795099b7..114079cfa 100644 --- a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart +++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart @@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activ import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; import 'package:fluffychat/pangea/utils/bot_style.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; +import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart'; import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/word_audio_button.dart'; @@ -99,7 +100,10 @@ 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)) { - widget.practiceCardController.onActivityFinish(); + MatrixState.pangeaController.getAnalytics.analyticsStream.stream.first + .then((_) { + widget.practiceCardController.onActivityFinish(); + }); } if (mounted) { @@ -129,6 +133,17 @@ class MultipleChoiceActivityState extends State { ttsController: widget.tts, eventID: widget.eventID, ), + if (practiceActivity.activityType == + ActivityTypeEnum.hiddenWordListening) + MessageAudioCard( + messageEvent: + widget.practiceCardController.widget.pangeaMessageEvent, + overlayController: + widget.practiceCardController.widget.overlayController, + tts: widget.practiceCardController.widget.overlayController.tts, + setIsPlayingAudio: widget.practiceCardController.widget + .overlayController.setIsPlayingAudio, + ), ChoicesArray( isLoading: false, uniqueKeyForLayerLink: (index) => "multiple_choice_$index", diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index 8b64c4d2a..3b616577f 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -19,7 +19,6 @@ import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart'; import 'package:fluffychat/pangea/widgets/content_issue_button.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart'; import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart'; -import 'package:fluffychat/pangea/widgets/practice_activity/target_tokens_controller.dart'; import 'package:fluffychat/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; @@ -32,14 +31,12 @@ class PracticeActivityCard extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; final MessageOverlayController overlayController; final TtsController ttsController; - final TargetTokensController targetTokensController; const PracticeActivityCard({ super.key, required this.pangeaMessageEvent, required this.overlayController, required this.ttsController, - required this.targetTokensController, }); @override @@ -56,6 +53,30 @@ class PracticeActivityCardState extends State { List get practiceActivities => widget.pangeaMessageEvent.practiceActivities; + PracticeActivityEvent? get existingActivityMatchingNeeds { + final messageAnalyticsEntry = pangeaController.getAnalytics.perMessage + .get(widget.pangeaMessageEvent, false); + + if (messageAnalyticsEntry?.nextActivityToken == null) { + debugger(when: kDebugMode); + return null; + } + + for (final existingActivity in practiceActivities) { + for (final c in messageAnalyticsEntry!.nextActivityToken!.constructs) { + if (existingActivity.practiceActivity.tgtConstructs + .any((tc) => tc == c.id) && + existingActivity.practiceActivity.activityType == + messageAnalyticsEntry.nextActivityType) { + debugPrint('found existing activity'); + return existingActivity; + } + } + } + + return null; + } + // Used to show an animation when the user completes an activity // while simultaneously fetching a new activity and not showing the loading spinner // until the appropriate time has passed to 'savor the joy' @@ -96,86 +117,118 @@ class PracticeActivityCardState extends State { /// If not, get a new activity from the server. Future initialize() async { _setPracticeActivity( - await _fetchNewActivity(), + await _fetchActivity(), ); } - Future _fetchNewActivity([ + Future _fetchActivity([ ActivityQualityFeedback? activityFeedback, ]) async { - try { - debugPrint('Fetching new activity'); - - _updateFetchingActivity(true); - - // target tokens can be empty if activities have been completed for each - // it's set on initialization and then removed when each activity is completed - if (!pangeaController.languageController.languagesSet) { - debugger(when: kDebugMode); - _updateFetchingActivity(false); - return null; - } - - if (!mounted) { - debugger(when: kDebugMode); - _updateFetchingActivity(false); - return null; - } - - if (widget.pangeaMessageEvent.messageDisplayRepresentation == null) { - debugger(when: kDebugMode); - _updateFetchingActivity(false); - ErrorHandler.logError( - e: Exception('No original message found in _fetchNewActivity'), - data: { - 'event': widget.pangeaMessageEvent.event.toJson(), - }, - ); - return null; - } - - final PracticeActivityModelResponse? activityResponse = - await pangeaController.practiceGenerationController - .getPracticeActivity( - MessageActivityRequest( - userL1: pangeaController.languageController.userL1!.langCode, - userL2: pangeaController.languageController.userL2!.langCode, - messageText: widget.pangeaMessageEvent.messageDisplayText, - tokensWithXP: await widget.targetTokensController.targetTokens( - widget.pangeaMessageEvent, - ), - messageId: widget.pangeaMessageEvent.eventId, - existingActivities: practiceActivities - .map((activity) => activity.activityRequestMetaData) - .toList(), - activityQualityFeedback: activityFeedback, - clientCompatibleActivities: widget - .ttsController.isLanguageFullySupported - ? ActivityTypeEnum.values - : ActivityTypeEnum.values - .where((type) => type != ActivityTypeEnum.wordFocusListening) - .toList(), - ), - widget.pangeaMessageEvent, - ); + // temporary + // try { + debugPrint('Fetching activity'); + // debugger(); + _updateFetchingActivity(true); + + // target tokens can be empty if activities have been completed for each + // it's set on initialization and then removed when each activity is completed + if (!pangeaController.languageController.languagesSet) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); + return null; + } - currentActivityCompleter = activityResponse?.eventCompleter; + if (!mounted) { + debugger(when: kDebugMode); _updateFetchingActivity(false); + return null; + } - return activityResponse?.activity; - } catch (e, s) { + if (widget.pangeaMessageEvent.messageDisplayRepresentation == null) { debugger(when: kDebugMode); + _updateFetchingActivity(false); ErrorHandler.logError( - e: e, - s: s, - m: 'Failed to get new activity', + e: Exception('No original message found in _fetchNewActivity'), data: { - 'activity': currentActivity, - 'record': currentCompletionRecord, + 'event': widget.pangeaMessageEvent.event.toJson(), }, ); return null; } + + if (widget.pangeaMessageEvent.messageDisplayRepresentation?.tokens == + null) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); + return null; + } + + final messageAnalyticsEntry = pangeaController.getAnalytics.perMessage + .get(widget.pangeaMessageEvent, false); + + // the client is going to be choosing the next activity now + // if nothing is set then it must be done with practice + if (messageAnalyticsEntry?.nextActivityToken == null || + messageAnalyticsEntry?.nextActivityType == null) { + debugger(when: kDebugMode); + _updateFetchingActivity(false); + return null; + } + + final existingActivity = existingActivityMatchingNeeds; + + if (existingActivity != null) { + return existingActivity.practiceActivity; + } + + debugPrint( + "client requesting activity type: ${messageAnalyticsEntry?.nextActivityType}", + ); + debugPrint( + "client requesting token: ${messageAnalyticsEntry?.nextActivityToken?.token.text.content}", + ); + + final PracticeActivityModelResponse? activityResponse = + await pangeaController.practiceGenerationController.getPracticeActivity( + MessageActivityRequest( + userL1: pangeaController.languageController.userL1!.langCode, + userL2: pangeaController.languageController.userL2!.langCode, + messageText: widget.pangeaMessageEvent.originalSent!.text, + tokensWithXP: messageAnalyticsEntry!.tokensWithXp, + messageId: widget.pangeaMessageEvent.eventId, + existingActivities: practiceActivities + .map((activity) => activity.activityRequestMetaData) + .toList(), + activityQualityFeedback: activityFeedback, + clientCompatibleActivities: widget + .ttsController.isLanguageFullySupported + ? ActivityTypeEnum.values + : ActivityTypeEnum.values + .where((type) => type != ActivityTypeEnum.wordFocusListening) + .toList(), + clientTokenRequest: messageAnalyticsEntry.nextActivityToken!, + clientTypeRequest: messageAnalyticsEntry.nextActivityType!, + ), + widget.pangeaMessageEvent, + ); + + currentActivityCompleter = activityResponse?.eventCompleter; + _updateFetchingActivity(false); + + return activityResponse?.activity; + // } catch (e, s) { + // debugger(when: kDebugMode); + // ErrorHandler.logError( + // e: e, + // s: s, + // m: 'Failed to get new activity', + // data: { + // 'activity': currentActivity, + // 'record': currentCompletionRecord, + // }, + // ); + // return null; + // } } ConstructUseMetaData get metadata => ConstructUseMetaData( @@ -220,13 +273,17 @@ class PracticeActivityCardState extends State { // update the target tokens with the new construct uses // NOTE - multiple choice activity is handling adding these to analytics - await widget.targetTokensController.updateTokensWithConstructs( - currentCompletionRecord!.usesForAllResponses( - currentActivity!, - metadata, - ), - widget.pangeaMessageEvent, - ); + + // previously we would update the tokens with the constructs + // now the tokens themselves calculate their own points using the analytics + // we're going to see if this creates performance issues + final messageAnalytics = MatrixState + .pangeaController.getAnalytics.perMessage + .get(widget.pangeaMessageEvent, false); + // messageAnalytics will only be null if there are no tokens to update + + // set the target types for the next activity + messageAnalytics!.computeTargetTypesForMessageAsync(); widget.overlayController.onActivityFinish(); pangeaController.activityRecordController.completeActivity( @@ -237,7 +294,7 @@ class PracticeActivityCardState extends State { // and setting it to replace the previous activity final Iterable result = await Future.wait([ _savorTheJoy(), - _fetchNewActivity(), + _fetchActivity(), ]); _setPracticeActivity(result.last as PracticeActivityModel?); @@ -279,7 +336,7 @@ class PracticeActivityCardState extends State { ); } - _fetchNewActivity( + _fetchActivity( ActivityQualityFeedback( feedbackText: feedback, badActivity: currentActivity!, @@ -315,34 +372,15 @@ class PracticeActivityCardState extends State { switch (currentActivity?.activityType) { case null: return null; - case ActivityTypeEnum.multipleChoice: - return MultipleChoiceActivity( - practiceCardController: this, - currentActivity: currentActivity!, - tts: widget.ttsController, - eventID: widget.pangeaMessageEvent.eventId, - ); case ActivityTypeEnum.wordFocusListening: - // return WordFocusListeningActivity( - // activity: currentActivity!, practiceCardController: this); + case ActivityTypeEnum.hiddenWordListening: + case ActivityTypeEnum.wordMeaning: return MultipleChoiceActivity( practiceCardController: this, currentActivity: currentActivity!, tts: widget.ttsController, eventID: widget.pangeaMessageEvent.eventId, ); - // default: - // ErrorHandler.logError( - // e: Exception('Unknown activity type'), - // m: 'Unknown activity type', - // data: { - // 'activityType': currentActivity!.activityType, - // }, - // ); - // return Text( - // L10n.of(context)!.oopsSomethingWentWrong, - // style: BotStyle.text(context), - // ); } } diff --git a/lib/pangea/widgets/practice_activity/target_tokens_controller.dart b/lib/pangea/widgets/practice_activity/target_tokens_controller.dart deleted file mode 100644 index 82d995ad6..000000000 --- a/lib/pangea/widgets/practice_activity/target_tokens_controller.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'dart:developer'; - -import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; -import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart'; -import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart'; -import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; -import 'package:flutter/foundation.dart'; - -/// Seperated out the target tokens from the practice activity card -/// in order to control the state of the target tokens -class TargetTokensController { - List? _targetTokens; - - TargetTokensController(); - - /// From the tokens in the message, do a preliminary filtering of which to target - /// Then get the construct uses for those tokens - Future> targetTokens( - PangeaMessageEvent pangeaMessageEvent, - ) async { - if (_targetTokens != null) { - return _targetTokens!; - } - - _targetTokens = await _initialize(pangeaMessageEvent); - - // final allConstructs = MatrixState - // .pangeaController.getAnalytics.analyticsStream.value?.constructs; - // await updateTokensWithConstructs( - // allConstructs ?? [], - // pangeaMessageEvent, - // ); - - return _targetTokens!; - } - - Future> _initialize( - PangeaMessageEvent pangeaMessageEvent, - ) async { - final tokens = - await pangeaMessageEvent.messageDisplayRepresentation?.tokensGlobal( - pangeaMessageEvent.senderId, - pangeaMessageEvent.originServerTs, - ); - - if (tokens == null || tokens.isEmpty) { - debugger(when: kDebugMode); - return _targetTokens = []; - } - - return _targetTokens = - tokens.map((token) => token.emptyTokenWithXP).toList(); - } - - Future updateTokensWithConstructs( - List constructUses, - pangeaMessageEvent, - ) async { - final ConstructListModel constructList = ConstructListModel( - uses: constructUses, - // type: null, - ); - - _targetTokens ??= await _initialize(pangeaMessageEvent); - - for (final token in _targetTokens!) { - // we don't need to do this for tokens that don't have saveVocab set to true - if (!token.token.lemma.saveVocab) { - continue; - } - - for (final construct in token.constructs) { - final constructUseModel = constructList.getConstructUses( - ConstructIdentifier( - lemma: construct.id.lemma, - type: construct.id.type, - category: construct.id.category, - ), - ); - if (constructUseModel != null) { - construct.xp += constructUseModel.points; - construct.lastUsed = constructUseModel.lastUsed; - } - } - } - } -}