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/chat.dart b/lib/pages/chat/chat.dart index e8aa4e3f9..9cc5cab5a 100644 --- a/lib/pages/chat/chat.dart +++ b/lib/pages/chat/chat.dart @@ -22,6 +22,7 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_e import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/choreo_record.dart'; +import 'package:fluffychat/pangea/models/pangea_token_model.dart'; import 'package:fluffychat/pangea/models/representation_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart'; @@ -1660,6 +1661,7 @@ class ChatController extends State // #Pangea void showToolbar( PangeaMessageEvent pangeaMessageEvent, { + PangeaToken? selectedToken, MessageMode? mode, Event? nextEvent, Event? prevEvent, @@ -1692,6 +1694,7 @@ class ChatController extends State chatController: this, event: pangeaMessageEvent.event, pangeaMessageEvent: pangeaMessageEvent, + selectedTokenOnInitialization: selectedToken, nextEvent: nextEvent, prevEvent: prevEvent, ); diff --git a/lib/pages/chat/events/message_content.dart b/lib/pages/chat/events/message_content.dart index 56dd7a174..a7ff96f21 100644 --- a/lib/pages/chat/events/message_content.dart +++ b/lib/pages/chat/events/message_content.dart @@ -4,7 +4,7 @@ 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_token_text.dart'; import 'package:fluffychat/pangea/widgets/chat/message_toolbar_selection_area.dart'; import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; @@ -306,25 +306,6 @@ class MessageContent extends StatelessWidget { height: 1.3, ); - if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens != - null) { - return MessageTokenText( - messageAnalyticsEntry: - controller.pangeaController.getAnalytics.perMessage.get( - pangeaMessageEvent!, - false, - )!, - style: messageTextStyle, - onClick: overlayController?.onClickOverlayMessageToken ?? - (_) => controller.showToolbar(pangeaMessageEvent!), - isSelected: overlayController?.isTokenSelected, - ); - } - - if (overlayController != null && pangeaMessageEvent != null) { - return overlayController!.messageTokenText; - } - if (immersionMode && pangeaMessageEvent != null) { return Flexible( child: PangeaRichText( @@ -336,6 +317,20 @@ class MessageContent extends StatelessWidget { ), ); } + + if (pangeaMessageEvent != null) { + return MessageTokenText( + pangeaMessageEvent: pangeaMessageEvent!, + tokens: + pangeaMessageEvent!.messageDisplayRepresentation?.tokens, + style: messageTextStyle, + onClick: overlayController?.onClickOverlayMessageToken ?? + (token) => controller.showToolbar(pangeaMessageEvent!, + selectedToken: token), + isSelected: overlayController?.isTokenSelected, + ); + } + // Pangea# return diff --git a/lib/pangea/controllers/message_analytics_controller.dart b/lib/pangea/controllers/message_analytics_controller.dart index 6bbbc9979..e3e60e0e1 100644 --- a/lib/pangea/controllers/message_analytics_controller.dart +++ b/lib/pangea/controllers/message_analytics_controller.dart @@ -2,8 +2,8 @@ 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:fluffychat/pangea/models/pangea_token_model.dart'; +import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart'; import 'package:flutter/foundation.dart'; /// Picks which tokens to do activities on and what types of activities to do @@ -11,125 +11,176 @@ import 'package:flutter/foundation.dart'; /// 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; +class TargetTokensAndActivityType { + final List tokens; + final ActivityTypeEnum activityType; - // - bool isFirstTimeComputing = true; + TargetTokensAndActivityType({ + required this.tokens, + required this.activityType, + }); - TokenWithXP? nextActivityToken; - ActivityTypeEnum? nextActivityType; + bool matchesActivity(PracticeActivityModel activity) { + // check if the existing activity has the same type as the target + if (activity.activityType != activityType) { + return false; + } - MessageAnalyticsEntry(this.pmEvent) { - debugPrint('making MessageAnalyticsEntry: ${pmEvent.messageDisplayText}'); - if (pmEvent.messageDisplayRepresentation?.tokens == null) { - throw Exception('No tokens in message in MessageAnalyticsEntry'); + // check that the activity matches at least one construct in the target tokens + // TODO - this is complicated so we need to verify it works + // maybe we just verify that the target span of the activity is the same as the target span of the target + final allTokenConstructs = + tokens.map((t) => t.constructs).expand((e) => e).toList(); + for (final c in allTokenConstructs) { + if (activity.tgtConstructs.any((tc) => tc == c.id)) { + debugPrint('found existing activity'); + return true; + } } - tokensWithXp = pmEvent.messageDisplayRepresentation!.tokens! - .map((token) => TokenWithXP(token: token)) - .toList(); - updateTargetTypesForMessage(); + return false; } - List get tokensThatCanBeHeard => - tokensWithXp.where((t) => t.token.canBeHeard).toList(); + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; - void updateTokenTargetTypes() { - // compute target types for each token - for (final token in tokensWithXp) { - token.targetTypes = []; + return other is TargetTokensAndActivityType && + listEquals(other.tokens, tokens) && + other.activityType == activityType; + } - if (!token.token.lemma.saveVocab) { - continue; - } + @override + int get hashCode => tokens.hashCode ^ activityType.hashCode; +} - if (token.daysSinceLastUse < 1) { - continue; - } +class MessageAnalyticsEntry { + final DateTime createdAt = DateTime.now(); - if (token.eligibleForActivity(ActivityTypeEnum.wordMeaning) && - !token.didActivity(ActivityTypeEnum.wordMeaning)) { - token.targetTypes.add(ActivityTypeEnum.wordMeaning); - } + late final List _tokens; - if (token.eligibleForActivity(ActivityTypeEnum.wordFocusListening) && - !token.didActivity(ActivityTypeEnum.wordFocusListening) && - tokensThatCanBeHeard.length > 3) { - token.targetTypes.add(ActivityTypeEnum.wordFocusListening); - } + late final bool _includeHiddenWordActivities; - if (token.eligibleForActivity(ActivityTypeEnum.hiddenWordListening) && - isFirstTimeComputing && - !token.didActivity(ActivityTypeEnum.hiddenWordListening) && - !pmEvent.ownMessage) { - token.targetTypes.add(ActivityTypeEnum.hiddenWordListening); - } - } + late final List _activityQueue; + + MessageAnalyticsEntry({ + required List tokens, + required bool includeHiddenWordActivities, + }) { + _tokens = tokens; + _includeHiddenWordActivities = includeHiddenWordActivities; + _activityQueue = setActivityQueue(); } - /// Updates the target types for each token in the message and the next - /// activity token and type. Called before requesting the next new activity. - void updateTargetTypesForMessage() { - // reset - nextActivityToken = null; - nextActivityType = null; - updateTokenTargetTypes(); - - // From the tokens with hiddenWordListening in targetTypes, pick one at random. - // Create a list of token indicies with hiddenWordListening type available. - final List withHiddenWordIndices = tokensWithXp - .asMap() - .entries - .where( - (entry) => entry.value.targetTypes.contains( - ActivityTypeEnum.hiddenWordListening, + TargetTokensAndActivityType? get nextActivity => + _activityQueue.isNotEmpty ? _activityQueue.first : null; + + bool get canDoWordFocusListening => + _tokens.where((t) => t.canBeHeard).length > 4; + + /// On initialization, we pick which tokens to do activities on and what types of activities to do + List setActivityQueue() { + final List queue = []; + + // for each token in the message + // pick a random activity type from the eligible types + for (final token in _tokens) { + // get all the eligible activity types for the token + // based on the context of the message + final eligibleTypesBasedOnContext = token.eligibleActivityTypes + .where((type) => type != ActivityTypeEnum.hiddenWordListening) + .where( + (type) => + canDoWordFocusListening || + type != ActivityTypeEnum.wordFocusListening, + ) + .toList(); + + // if there are no eligible types, continue to the next token + if (eligibleTypesBasedOnContext.isEmpty) continue; + + // chose a random activity type from the eligible types for that token + queue.add( + TargetTokensAndActivityType( + tokens: [token], + activityType: eligibleTypesBasedOnContext[ + Random().nextInt(eligibleTypesBasedOnContext.length)], + ), + ); + } + + // sort the queue by the total xp of the tokens, lowest first + queue.sort( + (a, b) => a.tokens.map((t) => t.xp).reduce((a, b) => a + b).compareTo( + b.tokens.map((t) => t.xp).reduce((a, b) => a + b), ), - ) - .map((entry) => entry.key) - .toList(); - - // randomly pick one index in the list and set the next activity - if (withHiddenWordIndices.isNotEmpty) { - final int randomIndex = - withHiddenWordIndices[Random().nextInt(withHiddenWordIndices.length)]; - - nextActivityToken = tokensWithXp[randomIndex]; - nextActivityType = ActivityTypeEnum.hiddenWordListening; - - // remove hiddenWord type from all other tokens - // there can only be one hidden word activity for a message - for (int i = 0; i < tokensWithXp.length; i++) { - if (i != randomIndex) { - tokensWithXp[i] - .targetTypes - .remove(ActivityTypeEnum.hiddenWordListening); + ); + + // if applicable, add a hidden word activity to the front of the queue + final hiddenWordActivity = getHiddenWordActivity(queue.length); + if (hiddenWordActivity != null) { + queue.insert(0, hiddenWordActivity); + } + + // limit to 3 activities + return queue.take(3).toList(); + } + + TargetTokensAndActivityType? getHiddenWordActivity(int numOtherActivities) { + // don't do hidden word listening on own messages + if (!_includeHiddenWordActivities) { + return null; + } + + // we will only do hidden word listening 50% of the time + // if there are no other activities to do, we will always do hidden word listening + if (numOtherActivities >= 3 && Random().nextDouble() < 0.5) { + return null; + } + + // We will find the longest sequence of tokens that have hiddenWordListening in their eligibleActivityTypes + final List> sequences = []; + List currentSequence = []; + for (final token in _tokens) { + if (token.eligibleActivityTypes + .contains(ActivityTypeEnum.hiddenWordListening)) { + currentSequence.add(token); + } else { + if (currentSequence.isNotEmpty) { + sequences.add(currentSequence); + currentSequence = []; } } } - // 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; + if (sequences.isEmpty) { + return null; + } + + final longestSequence = sequences.reduce( + (a, b) => a.length > b.length ? a : b, + ); + + return TargetTokensAndActivityType( + tokens: longestSequence, + activityType: ActivityTypeEnum.hiddenWordListening, + ); + } - isFirstTimeComputing = false; + void onActivityComplete(PracticeActivityModel completed) { + _activityQueue.removeWhere( + (a) => a.matchesActivity(completed), + ); } void revealAllTokens() { - for (final token in tokensWithXp) { - token.targetTypes.remove(ActivityTypeEnum.hiddenWordListening); - } + _activityQueue.removeWhere((a) => a.activityType.hiddenType); } - bool get shouldHideToken => tokensWithXp.any( - (token) => - token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening), + bool isTokenInHiddenWordActivity(PangeaToken token) => _activityQueue.any( + (activity) => + activity.tokens.contains(token) && activity.activityType.hiddenType, ); } @@ -156,22 +207,25 @@ class MessageAnalyticsController { } } + String _key(List tokens) => PangeaToken.reconstructText(tokens); + MessageAnalyticsEntry? get( - PangeaMessageEvent pmEvent, - bool refresh, + List tokens, + bool includeHiddenWordActivities, ) { - if (pmEvent.messageDisplayRepresentation?.tokens == null) { - return null; - } + final String key = _key(tokens); - if (_cache.containsKey(pmEvent.messageDisplayText) && !refresh) { - return _cache[pmEvent.messageDisplayText]; + if (_cache.containsKey(key)) { + return _cache[key]; } - _cache[pmEvent.messageDisplayText] = MessageAnalyticsEntry(pmEvent); + _cache[key] = MessageAnalyticsEntry( + tokens: tokens, + includeHiddenWordActivities: includeHiddenWordActivities, + ); clean(); - return _cache[pmEvent.messageDisplayText]; + return _cache[key]; } } diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart index c5b2c0c4a..69504d9d2 100644 --- a/lib/pangea/controllers/practice_activity_generation_controller.dart +++ b/lib/pangea/controllers/practice_activity_generation_controller.dart @@ -90,17 +90,10 @@ 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); - throw Exception('Failed to convert speech to text'); + throw Exception('Failed to create activity'); } } @@ -123,49 +116,15 @@ class PracticeGenerationController { requestModel: req, ); - if (res.finished) { - debugPrint('Activity generation finished'); - return null; - } - final eventCompleter = Completer(); - // if the server points to an existing event, return that event - if (res.existingActivityEventId != null) { - final Event? existingEvent = - await event.room.getEventById(res.existingActivityEventId!); - - debugPrint( - 'Existing activity event found: ${existingEvent?.content}', - ); - debugPrint( - "eventID: ${existingEvent?.eventId}, event is redacted: ${existingEvent?.redacted}", - ); - if (existingEvent != null && !existingEvent.redacted) { - final activityEvent = PracticeActivityEvent( - event: existingEvent, - timeline: event.timeline, - ); - eventCompleter.complete(activityEvent); - return PracticeActivityModelResponse( - activity: activityEvent.practiceActivity, - eventCompleter: eventCompleter, - ); - } - } - - if (res.activity == null) { - debugPrint('No activity generated'); - return null; - } - - debugPrint('Activity generated: ${res.activity!.toJson()}'); - _sendAndPackageEvent(res.activity!, event).then((event) { + debugPrint('Activity generated: ${res.activity.toJson()}'); + _sendAndPackageEvent(res.activity, event).then((event) { eventCompleter.complete(event); }); final responseModel = PracticeActivityModelResponse( - activity: res.activity!, + activity: res.activity, eventCompleter: eventCompleter, ); diff --git a/lib/pangea/enum/activity_type_enum.dart b/lib/pangea/enum/activity_type_enum.dart index fc11c2e0d..909ac9378 100644 --- a/lib/pangea/enum/activity_type_enum.dart +++ b/lib/pangea/enum/activity_type_enum.dart @@ -14,6 +14,16 @@ extension ActivityTypeExtension on ActivityTypeEnum { } } + bool get hiddenType { + switch (this) { + case ActivityTypeEnum.wordMeaning: + case ActivityTypeEnum.wordFocusListening: + return false; + case ActivityTypeEnum.hiddenWordListening: + return true; + } + } + ActivityTypeEnum fromString(String value) { final split = value.split('.').last; switch (split) { @@ -42,19 +52,19 @@ extension ActivityTypeExtension on ActivityTypeEnum { return [ ConstructUseTypeEnum.corPA, ConstructUseTypeEnum.incPA, - ConstructUseTypeEnum.ignPA + ConstructUseTypeEnum.ignPA, ]; case ActivityTypeEnum.wordFocusListening: return [ ConstructUseTypeEnum.corWL, ConstructUseTypeEnum.incWL, - ConstructUseTypeEnum.ignWL + ConstructUseTypeEnum.ignWL, ]; case ActivityTypeEnum.hiddenWordListening: return [ ConstructUseTypeEnum.corHWL, ConstructUseTypeEnum.incHWL, - ConstructUseTypeEnum.ignHWL + ConstructUseTypeEnum.ignHWL, ]; } } diff --git a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart index 3de2e2ffc..f97ba753c 100644 --- a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -2,7 +2,6 @@ import 'dart:developer'; import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.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 'package:matrix/matrix.dart'; @@ -76,11 +75,4 @@ class PracticeActivityEvent { // DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs; String get parentMessageId => event.relationshipEventId!; - - ExistingActivityMetaData get activityRequestMetaData => - ExistingActivityMetaData( - activityEventId: event.eventId, - tgtConstructs: practiceActivity.tgtConstructs, - activityType: practiceActivity.activityType, - ); } diff --git a/lib/pangea/models/pangea_token_model.dart b/lib/pangea/models/pangea_token_model.dart index 8d292dfd1..c3d487731 100644 --- a/lib/pangea/models/pangea_token_model.dart +++ b/lib/pangea/models/pangea_token_model.dart @@ -1,9 +1,13 @@ import 'dart:developer'; import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.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/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/widgets/matrix.dart'; import 'package:flutter/foundation.dart'; import '../constants/model_keys.dart'; @@ -31,6 +35,18 @@ class PangeaToken { required this.morph, }); + @override + bool operator ==(Object other) { + if (other is PangeaToken) { + return other.text.content == text.content && + other.text.offset == text.offset; + } + return false; + } + + @override + int get hashCode => text.content.hashCode ^ text.offset.hashCode; + /// reconstructs the text from the tokens /// [tokens] - the tokens to reconstruct /// [debugWalkThrough] - if true, will start the debugger @@ -151,6 +167,186 @@ class PangeaToken { category: pos, ); } + + bool _isActivityBasicallyEligible(ActivityTypeEnum a) { + switch (a) { + case ActivityTypeEnum.wordMeaning: + // return isContentWord; + return true; + case ActivityTypeEnum.wordFocusListening: + case ActivityTypeEnum.hiddenWordListening: + return 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() == 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() == text.content.toLowerCase(),) + .map((u) => u.useType) + .any((u) => a.associatedUseTypes.contains(u)); + } + } + + bool _didActivitySuccessfully(ActivityTypeEnum a) { + switch (a) { + case ActivityTypeEnum.wordMeaning: + return vocabConstruct.uses + .map((u) => u.useType) + .any((u) => u == ConstructUseTypeEnum.corPA); + case ActivityTypeEnum.wordFocusListening: + return vocabConstruct.uses + // TODO - double-check that form is going to be available here + // .where((u) => + // u.form?.toLowerCase() == text.content.toLowerCase(),) + .map((u) => u.useType) + .any((u) => u == ConstructUseTypeEnum.corWL); + case ActivityTypeEnum.hiddenWordListening: + return vocabConstruct.uses + // TODO - double-check that form is going to be available here + // .where((u) => + // u.form?.toLowerCase() == text.content.toLowerCase(),) + .map((u) => u.useType) + .any((u) => u == ConstructUseTypeEnum.corHWL); + } + } + + bool isActivityProbablyLevelAppropriate(ActivityTypeEnum a) { + debugger(when: kDebugMode); + final int points = vocabConstruct.points; + final int myxp = xp; + switch (a) { + case ActivityTypeEnum.wordMeaning: + return vocabConstruct.points < 15; + case ActivityTypeEnum.wordFocusListening: + return !_didActivitySuccessfully(a); + case ActivityTypeEnum.hiddenWordListening: + return true; + } + } + + bool shouldDoActivity(ActivityTypeEnum a) { + final bool notEmpty = text.content.trim().isNotEmpty; + final bool isEligible = _isActivityBasicallyEligible(a); + final bool isProbablyLevelAppropriate = + isActivityProbablyLevelAppropriate(a); + + return notEmpty && isEligible && isProbablyLevelAppropriate; + } + + List get eligibleActivityTypes { + final List eligibleActivityTypes = []; + + if (!lemma.saveVocab || daysSinceLastUse < 1) { + return eligibleActivityTypes; + } + + for (final type in ActivityTypeEnum.values) { + if (_isActivityBasicallyEligible(type) && + !_didActivitySuccessfully(type)) { + eligibleActivityTypes.add(type); + } + } + + return eligibleActivityTypes; + } + + ConstructUses get vocabConstruct { + final vocab = constructs.firstWhereOrNull( + (element) => element.id.type == ConstructTypeEnum.vocab, + ); + if (vocab == null) { + return ConstructUses( + lemma: lemma.text, + constructType: ConstructTypeEnum.vocab, + category: 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; + } + + List get _constructIDs { + final List ids = []; + ids.add( + ConstructIdentifier( + lemma: lemma.text, + type: ConstructTypeEnum.vocab, + category: pos, + ), + ); + for (final morph in morph.entries) { + ids.add( + ConstructIdentifier( + lemma: morph.value, + type: ConstructTypeEnum.morph, + category: morph.key, + ), + ); + } + return ids; + } + + List get constructs => _constructIDs + .map( + (id) => + MatrixState.pangeaController.getAnalytics.constructListModel + .getConstructUses(id) ?? + ConstructUses( + lemma: id.lemma, + constructType: id.type, + category: id.category, + uses: [], + ), + ) + .toList(); + + Map toServerChoiceTokenWithXP() { + return { + 'token': toJson(), + 'constructs_with_xp': constructs.map((e) => e.toJson()).toList(), + 'target_types': eligibleActivityTypes.map((e) => e.string).toList(), + }; + } } class PangeaTokenText { 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 159e37870..c920aba0b 100644 --- a/lib/pangea/models/practice_activities.dart/message_activity_request.dart +++ b/lib/pangea/models/practice_activities.dart/message_activity_request.dart @@ -1,193 +1,7 @@ import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/enum/activity_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'; -import 'package:fluffychat/widgets/matrix.dart'; - -class TokenWithXP { - final PangeaToken token; - late List targetTypes; - - TokenWithXP({ - required this.token, - }) { - targetTypes = []; - } - - 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; - } - - 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), - ); - } - - Map toJson() { - 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 && - const ListEquality().equals(other.constructs, constructs); - } - - @override - int get hashCode { - return const ListEquality().hash(constructs); - } -} - -class ExistingActivityMetaData { - final String activityEventId; - final List tgtConstructs; - final ActivityTypeEnum activityType; - - ExistingActivityMetaData({ - required this.activityEventId, - required this.tgtConstructs, - required this.activityType, - }); - - factory ExistingActivityMetaData.fromJson(Map json) { - return ExistingActivityMetaData( - activityEventId: json['activity_event_id'] as String, - tgtConstructs: ((json['tgt_constructs'] ?? json['target_constructs']) - as List) - .map((e) => ConstructIdentifier.fromJson(e as Map)) - .toList(), - activityType: ActivityTypeEnum.values.firstWhere( - (element) => - element.string == json['activity_type'] as String || - element.string.split('.').last == json['activity_type'] as String, - ), - ); - } - - Map toJson() { - return { - 'activity_event_id': activityEventId, - 'target_constructs': tgtConstructs.map((e) => e.toJson()).toList(), - 'activity_type': activityType.string, - }; - } -} // includes feedback text and the bad activity model class ActivityQualityFeedback { @@ -235,102 +49,79 @@ class MessageActivityRequest { final String userL2; final String messageText; + final List messageTokens; - final ActivityQualityFeedback? activityQualityFeedback; - - /// tokens with their associated constructs and xp - final List tokensWithXP; - - /// make the server aware of existing activities for potential reuse - final List existingActivities; - - final String messageId; + final List targetTokens; + final ActivityTypeEnum targetType; - final List clientCompatibleActivities; - - final ActivityTypeEnum clientTypeRequest; - - final TokenWithXP clientTokenRequest; + final ActivityQualityFeedback? activityQualityFeedback; MessageActivityRequest({ required this.userL1, required this.userL2, required this.messageText, - required this.tokensWithXP, - required this.messageId, - required this.existingActivities, + required this.messageTokens, required this.activityQualityFeedback, - required this.clientCompatibleActivities, - required this.clientTokenRequest, - required this.clientTypeRequest, - }); + required this.targetTokens, + required this.targetType, + }) { + if (targetTokens.isEmpty) { + throw Exception('Target tokens must not be empty'); + } + if ([ActivityTypeEnum.wordFocusListening, ActivityTypeEnum.wordMeaning] + .contains(targetType) && + targetTokens.length > 1) { + throw Exception( + 'Target tokens must be a single token for this activity type', + ); + } + } Map toJson() { return { 'user_l1': userL1, 'user_l2': userL2, 'message_text': messageText, - 'tokens_with_xp': tokensWithXP.map((e) => e.toJson()).toList(), - 'message_id': messageId, - 'existing_activities': existingActivities.map((e) => e.toJson()).toList(), + 'message_tokens': messageTokens.map((e) => e.toJson()).toList(), 'activity_quality_feedback': activityQualityFeedback?.toJson(), - 'iso_8601_time_of_req': DateTime.now().toIso8601String(), - // this is a list of activity types that the client can handle - // the server will only return activities of these types - // 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(), + 'target_tokens': targetTokens.map((e) => e.toJson()).toList(), + 'target_type': targetType.string, }; } - // equals accounts for message_id and last_used of each token @override bool operator ==(Object other) { if (identical(this, other)) return true; return other is MessageActivityRequest && - other.messageId == messageId && - const ListEquality().equals(other.tokensWithXP, tokensWithXP); + other.messageText == messageText && + other.targetType == targetType && + other.activityQualityFeedback?.feedbackText == + activityQualityFeedback?.feedbackText && + const ListEquality().equals(other.targetTokens, targetTokens); } @override int get hashCode { - return messageId.hashCode ^ - const ListEquality().hash(tokensWithXP) ^ - activityQualityFeedback.hashCode; + return messageText.hashCode ^ + targetType.hashCode ^ + activityQualityFeedback.hashCode ^ + targetTokens.hashCode; } } class MessageActivityResponse { - final PracticeActivityModel? activity; - final bool finished; - final String? existingActivityEventId; + final PracticeActivityModel activity; MessageActivityResponse({ required this.activity, - required this.finished, - required this.existingActivityEventId, }); factory MessageActivityResponse.fromJson(Map json) { return MessageActivityResponse( - activity: json['activity'] != null - ? PracticeActivityModel.fromJson( - json['activity'] as Map, - ) - : null, - finished: json['finished'] as bool, - existingActivityEventId: json['existing_activity_event_id'] as String?, + activity: PracticeActivityModel.fromJson( + json['activity'] as Map, + ), ); } - - Map toJson() { - return { - 'activity': activity?.toJson(), - 'finished': finished, - 'existing_activity_event_id': existingActivityEventId, - }; - } } diff --git a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart index 9c6468db9..f96780841 100644 --- a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart @@ -58,4 +58,20 @@ class ActivityContent { 'span_display_details': spanDisplayDetails?.toJson(), }; } + + // ovveride operator == and hashCode + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is ActivityContent && + other.question == question && + other.choices == choices && + other.answer == answer; + } + + @override + int get hashCode { + return question.hashCode ^ choices.hashCode ^ answer.hashCode; + } } 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 927f6b5a2..13fdcf6c8 100644 --- a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -189,14 +189,12 @@ class PracticeActivityRequest { class PracticeActivityModel { final List tgtConstructs; final String langCode; - final String msgId; final ActivityTypeEnum activityType; final ActivityContent content; PracticeActivityModel({ required this.tgtConstructs, required this.langCode, - required this.msgId, required this.activityType, required this.content, }); @@ -223,7 +221,6 @@ class PracticeActivityModel { .map((e) => ConstructIdentifier.fromJson(e as Map)) .toList(), langCode: json['lang_code'] as String, - msgId: json['msg_id'] as String, activityType: ActivityTypeEnum.wordMeaning.fromString(json['activity_type']), content: ActivityContent.fromJson(contentMap), @@ -237,7 +234,6 @@ class PracticeActivityModel { return { 'target_constructs': tgtConstructs.map((e) => e.toJson()).toList(), 'lang_code': langCode, - 'msg_id': msgId, 'activity_type': activityType.string, 'content': content.toJson(), }; @@ -251,7 +247,6 @@ class PracticeActivityModel { return other is PracticeActivityModel && const ListEquality().equals(other.tgtConstructs, tgtConstructs) && other.langCode == langCode && - other.msgId == msgId && other.activityType == activityType && other.content == content; } @@ -260,7 +255,6 @@ class PracticeActivityModel { int get hashCode { return const ListEquality().hash(tgtConstructs) ^ langCode.hashCode ^ - msgId.hashCode ^ activityType.hashCode ^ content.hashCode; } diff --git a/lib/pangea/network/urls.dart b/lib/pangea/network/urls.dart index 6a4ba5607..328f3e500 100644 --- a/lib/pangea/network/urls.dart +++ b/lib/pangea/network/urls.dart @@ -59,7 +59,7 @@ class PApiUrls { static String speechToText = "${PApiUrls.choreoEndpoint}/speech_to_text"; static String messageActivityGeneration = - "${PApiUrls.choreoEndpoint}/practice/message"; + "${PApiUrls.choreoEndpoint}/practice"; ///-------------------------------- revenue cat -------------------------- diff --git a/lib/pangea/widgets/chat/message_selection_overlay.dart b/lib/pangea/widgets/chat/message_selection_overlay.dart index be0101bf7..790145f98 100644 --- a/lib/pangea/widgets/chat/message_selection_overlay.dart +++ b/lib/pangea/widgets/chat/message_selection_overlay.dart @@ -6,13 +6,14 @@ import 'package:fluffychat/config/setting_keys.dart'; import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/events/message_reactions.dart'; +import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; import 'package:fluffychat/pangea/enum/activity_display_instructions_enum.dart'; +import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; import 'package:fluffychat/pangea/enum/message_mode_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/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'; @@ -28,24 +29,25 @@ import 'package:matrix/matrix.dart'; class MessageSelectionOverlay extends StatefulWidget { final ChatController chatController; - late final Event _event; - late final Event? _nextEvent; - late final Event? _prevEvent; - late final PangeaMessageEvent _pangeaMessageEvent; + final Event _event; + final Event? _nextEvent; + final Event? _prevEvent; + final PangeaMessageEvent _pangeaMessageEvent; + final PangeaToken? _selectedTokenOnInitialization; - MessageSelectionOverlay({ + const MessageSelectionOverlay({ required this.chatController, required Event event, required PangeaMessageEvent pangeaMessageEvent, + required PangeaToken? selectedTokenOnInitialization, required Event? nextEvent, required Event? prevEvent, super.key, - }) { - _pangeaMessageEvent = pangeaMessageEvent; - _nextEvent = nextEvent; - _prevEvent = prevEvent; - _event = event; - } + }) : _selectedTokenOnInitialization = selectedTokenOnInitialization, + _pangeaMessageEvent = pangeaMessageEvent, + _nextEvent = nextEvent, + _prevEvent = prevEvent, + _event = event; @override MessageOverlayController createState() => MessageOverlayController(); @@ -76,6 +78,17 @@ class MessageOverlayController extends State bool get showToolbarButtons => !widget._pangeaMessageEvent.isAudioMessage; + PangeaToken? get selectedTargetTokenForWordMeaning => + widget._selectedTokenOnInitialization != null && + !(messageAnalyticsEntry?.isTokenInHiddenWordActivity( + widget._selectedTokenOnInitialization!, + ) ?? + false) && + widget._selectedTokenOnInitialization! + .shouldDoActivity(ActivityTypeEnum.wordMeaning) + ? widget._selectedTokenOnInitialization + : null; + List? tokens; @override @@ -113,30 +126,24 @@ class MessageOverlayController extends State ).listen((_) => setState(() {})); tts.setupTTS(); - setInitialToolbarMode(); + + _setInitialToolbarModeAndSelectedSpan(); } - 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, - ); + MessageAnalyticsEntry? get messageAnalyticsEntry => tokens != null + ? MatrixState.pangeaController.getAnalytics.perMessage.get( + tokens!, + // this logic should be in the controller + !pangeaMessageEvent.ownMessage && + pangeaMessageEvent.messageDisplayRepresentation?.tokens != null, + ) + : null; Future _getTokens() async { tokens = pangeaMessageEvent.originalSent?.tokens; if (pangeaMessageEvent.originalSent != null && tokens == null) { + debugPrint("fetching tokens"); pangeaMessageEvent.originalSent! .tokensGlobal( pangeaMessageEvent.senderId, @@ -144,7 +151,8 @@ class MessageOverlayController extends State ) .then((tokens) { // this isn't currently working because originalSent's _event is null - setState(() => this.tokens = tokens); + this.tokens = tokens; + _setInitialToolbarModeAndSelectedSpan(); }); } } @@ -201,31 +209,38 @@ class MessageOverlayController extends State setState(() {}); } - Future setInitialToolbarMode() async { + Future _setInitialToolbarModeAndSelectedSpan() async { + debugPrint( + "setting initial toolbar mode and selected span with tokens $tokens", + ); + if (widget._pangeaMessageEvent.isAudioMessage) { toolbarMode = MessageMode.speechToText; - return; + return setState(() => toolbarMode = MessageMode.practiceActivity); } - // if (!messageInUserL2) { - // activitiesLeftToComplete = 0; - // toolbarMode = MessageMode.nullMode; - // return; - // } - - if (activitiesLeftToComplete > 0) { - toolbarMode = MessageMode.practiceActivity; - return; + + // we're only going to do activities if we have tokens for the message + if (tokens != null) { + // if the user selects a span on initialization, then we want to give + // them a practice activity on that word + if (selectedTargetTokenForWordMeaning != null) { + _selectedSpan = selectedTargetTokenForWordMeaning?.text; + return setState(() => toolbarMode = MessageMode.practiceActivity); + } + + if (activitiesLeftToComplete > 0) { + return setState(() => toolbarMode = MessageMode.practiceActivity); + } } + // Note: this setting is now hidden so this will always be false + // leaving this here in case we want to bring it back if (MatrixState.pangeaController.userController.profile.userSettings .autoPlayMessages) { - toolbarMode = MessageMode.textToSpeech; - return; + return setState(() => toolbarMode = MessageMode.textToSpeech); } - toolbarMode = MessageMode.translation; - - setState(() {}); + setState(() => toolbarMode = MessageMode.translation); } updateToolbarMode(MessageMode mode) { diff --git a/lib/pangea/widgets/chat/message_token_text.dart b/lib/pangea/widgets/chat/message_token_text.dart index 96cf3ae13..8a1829caa 100644 --- a/lib/pangea/widgets/chat/message_token_text.dart +++ b/lib/pangea/widgets/chat/message_token_text.dart @@ -1,82 +1,101 @@ -import 'package:fluffychat/config/app_config.dart'; -import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; +import 'package:collection/collection.dart'; +import 'package:fluffychat/pangea/controllers/message_analytics_controller.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 MessageTokenText extends StatelessWidget { - final PangeaController pangeaController = MatrixState.pangeaController; + final PangeaMessageEvent _pangeaMessageEvent; - final bool ownMessage; + final List? _tokens; - /// this must match the tokens or we've got problems - final String fullText; + final TextStyle _style; - /// this must match the fullText or we've got problems - final List? tokensWithDisplay; - final void Function(PangeaToken)? onClick; + final bool Function(PangeaToken)? _isSelected; + final void Function(PangeaToken)? _onClick; - MessageTokenText({ + const MessageTokenText({ super.key, - required this.ownMessage, - required this.fullText, - required this.tokensWithDisplay, - required this.onClick, - }); + required PangeaMessageEvent pangeaMessageEvent, + required List? tokens, + required TextStyle style, + required void Function(PangeaToken)? onClick, + bool Function(PangeaToken)? isSelected, + }) : _onClick = onClick, + _isSelected = isSelected, + _style = style, + _tokens = tokens, + _pangeaMessageEvent = pangeaMessageEvent; + + MessageAnalyticsEntry? get messageAnalyticsEntry => _tokens != null + ? MatrixState.pangeaController.getAnalytics.perMessage.get( + _tokens!, + // this logic should be in the controller + !_pangeaMessageEvent.ownMessage && + _pangeaMessageEvent.messageDisplayRepresentation?.tokens != null, + ) + : null; @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) { + if (_tokens == null) { return Text( - fullText, - style: style, + _pangeaMessageEvent.messageDisplayText, + style: _style, ); } // Convert the entire message into a list of characters - final Characters messageCharacters = fullText.characters; + final Characters messageCharacters = + _pangeaMessageEvent.messageDisplayText.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; + for (final token + in _pangeaMessageEvent.messageDisplayRepresentation!.tokens!) { + final start = token.start; + final end = 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; + final hideContent = + messageAnalyticsEntry?.isTokenInHiddenWordActivity(token) ?? false; + if (globalIndex < startIndex) { - tokenPositions.add(TokenPosition(start: globalIndex, end: startIndex)); + tokenPositions.add( + TokenPosition( + start: globalIndex, + end: startIndex, + hideContent: false, + highlight: _isSelected?.call(token) ?? false, + ), + ); } tokenPositions.add( TokenPosition( start: startIndex, end: endIndex, - tokenIndex: i, - token: tokenWithDisplay, + token: token, + hideContent: hideContent, + highlight: (_isSelected?.call(token) ?? false) && !hideContent, ), ); globalIndex = endIndex; } - //TODO - take out of build function of every message return RichText( text: TextSpan( - children: tokenPositions.map((tokenPosition) { + children: + tokenPositions.mapIndexed((int i, TokenPosition tokenPosition) { final substring = messageCharacters .skip(tokenPosition.start) .take(tokenPosition.end - tokenPosition.start) @@ -85,13 +104,15 @@ class MessageTokenText extends StatelessWidget { if (tokenPosition.token != null) { return TextSpan( recognizer: TapGestureRecognizer() - ..onTap = () => onClick != null - ? onClick!(tokenPosition.token!.token) + ..onTap = () => _onClick != null && tokenPosition.token != null + ? _onClick!(tokenPosition.token!) : null, - text: !tokenPosition.token!.hideContent ? substring : '_____', - style: style.merge( + text: !tokenPosition.hideContent + ? substring + : '_' * substring.length, + style: _style.merge( TextStyle( - backgroundColor: tokenPosition.token!.highlight + backgroundColor: tokenPosition.highlight ? Theme.of(context).brightness == Brightness.light ? Colors.black.withOpacity(0.4) : Colors.white.withOpacity(0.4) @@ -101,8 +122,12 @@ class MessageTokenText extends StatelessWidget { ); } else { return TextSpan( - text: substring, - style: style, + text: (i > 0 || i < tokenPositions.length - 1) && + tokenPositions[i + 1].hideContent && + tokenPositions[i - 1].hideContent + ? '_' * substring.length + : substring, + style: _style, ); } }).toList(), @@ -111,28 +136,18 @@ class MessageTokenText extends StatelessWidget { } } -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; + 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, - 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 deleted file mode 100644 index fcc0fe7d6..000000000 --- a/lib/pangea/widgets/chat/message_token_text_stateful.dart +++ /dev/null @@ -1,128 +0,0 @@ -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 MessageTokenText extends StatelessWidget { - final PangeaController pangeaController = MatrixState.pangeaController; - - final MessageAnalyticsEntry messageAnalyticsEntry; - - final TextStyle style; - - final bool Function(PangeaToken)? isSelected; - final void Function(PangeaToken)? onClick; - bool get ownMessage => messageAnalyticsEntry.pmEvent.ownMessage; - - MessageTokenText({ - super.key, - required this.messageAnalyticsEntry, - required this.style, - required this.onClick, - this.isSelected, - }); - - 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; - - final hideContent = - token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening); - - if (globalIndex < startIndex) { - tokenPositions.add( - TokenPosition( - start: globalIndex, - end: startIndex, - hideContent: false, - highlight: isSelected?.call(token.token) ?? false, - ), - ); - } - - tokenPositions.add( - TokenPosition( - start: startIndex, - end: endIndex, - token: token.token, - hideContent: hideContent, - highlight: (isSelected?.call(token.token) ?? false) && !hideContent, - ), - ); - 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 42ed552f7..f8fb574bf 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -72,7 +72,8 @@ class MessageToolbar extends StatelessWidget { return FutureBuilder( //TODO - convert this to synchronous if possible future: Future.value( - pangeaMessageEvent.messageDisplayRepresentation?.tokens), + pangeaMessageEvent.messageDisplayRepresentation?.tokens, + ), builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { return const ToolbarContentLoadingIndicator(); @@ -127,6 +128,8 @@ class MessageToolbar extends StatelessWidget { ); } return PracticeActivityCard( + selectedTargetTokenForWordMeaning: + overLayController.selectedTargetTokenForWordMeaning, pangeaMessageEvent: pangeaMessageEvent, overlayController: overLayController, ttsController: ttsController, diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart index c87cb83e8..504a3c9f5 100644 --- a/lib/pangea/widgets/practice_activity/practice_activity_card.dart +++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:developer'; +import 'package:collection/collection.dart'; import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart'; @@ -9,6 +10,7 @@ import 'package:fluffychat/pangea/enum/activity_type_enum.dart'; 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/pangea/models/analytics/constructs_model.dart'; +import 'package:fluffychat/pangea/models/pangea_token_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:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart'; @@ -31,12 +33,14 @@ class PracticeActivityCard extends StatefulWidget { final PangeaMessageEvent pangeaMessageEvent; final MessageOverlayController overlayController; final TtsController ttsController; + final PangeaToken? selectedTargetTokenForWordMeaning; const PracticeActivityCard({ super.key, required this.pangeaMessageEvent, required this.overlayController, required this.ttsController, + required this.selectedTargetTokenForWordMeaning, }); @override @@ -53,31 +57,6 @@ class PracticeActivityCardState extends State { List get practiceActivities => widget.pangeaMessageEvent.practiceActivities; - MessageAnalyticsEntry? get messageAnalyticsEntry => - MatrixState.pangeaController.getAnalytics.perMessage - .get(widget.pangeaMessageEvent, false); - - PracticeActivityEvent? get existingActivityMatchingNeeds { - 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' @@ -118,72 +97,63 @@ class PracticeActivityCardState extends State { /// If not, get a new activity from the server. Future initialize() async { _setPracticeActivity( - await _fetchActivity(), + await _fetchActivity( + selectedTargetTokenForWordMeaning: + widget.selectedTargetTokenForWordMeaning, + ), ); } - Future _fetchActivity([ + Future _fetchActivity({ ActivityQualityFeedback? activityFeedback, - ]) async { - // temporary + PangeaToken? selectedTargetTokenForWordMeaning, + }) async { // 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; - } - - if (!mounted) { - debugger(when: kDebugMode); - _updateFetchingActivity(false); - return null; - } - - if (widget.pangeaMessageEvent.messageDisplayRepresentation == null) { + if (!mounted || + !pangeaController.languageController.languagesSet || + widget.overlayController.messageAnalyticsEntry == null) { debugger(when: kDebugMode); _updateFetchingActivity(false); - ErrorHandler.logError( - e: Exception('No original message found in _fetchNewActivity'), - data: { - 'event': widget.pangeaMessageEvent.event.toJson(), - }, - ); return null; } - if (widget.pangeaMessageEvent.messageDisplayRepresentation?.tokens == - null) { - debugger(when: kDebugMode); - _updateFetchingActivity(false); - return null; - } + // if the user selected a token which is not already in a hidden word activity, + // we're going to give them an activity on that token first + // otherwise, we're going to give them an activity on the next token in the queue + final TargetTokensAndActivityType? nextActivitySpecs = + selectedTargetTokenForWordMeaning != null + ? TargetTokensAndActivityType( + tokens: [selectedTargetTokenForWordMeaning], + activityType: ActivityTypeEnum.wordMeaning, + ) + : widget.overlayController.messageAnalyticsEntry?.nextActivity; // 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); + if (nextActivitySpecs == null) { + debugPrint("No next activity set, exiting practice flow"); _updateFetchingActivity(false); return null; } - final existingActivity = existingActivityMatchingNeeds; + final existingActivity = practiceActivities.firstWhereOrNull( + (activity) => + nextActivitySpecs.matchesActivity(activity.practiceActivity), + ); if (existingActivity != null) { + debugPrint('found existing activity'); + _updateFetchingActivity(false); return existingActivity.practiceActivity; } debugPrint( - "client requesting activity type: ${messageAnalyticsEntry?.nextActivityType}", - ); - debugPrint( - "client requesting token: ${messageAnalyticsEntry?.nextActivityToken?.token.text.content}", + "client requesting ${nextActivitySpecs.activityType.string} for ${nextActivitySpecs.tokens.map((t) => t.text).join(' ')}", ); final PracticeActivityModelResponse? activityResponse = @@ -192,20 +162,10 @@ class PracticeActivityCardState extends State { 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(), + messageTokens: widget.overlayController.tokens!, activityQualityFeedback: activityFeedback, - clientCompatibleActivities: widget - .ttsController.isLanguageFullySupported - ? ActivityTypeEnum.values - : ActivityTypeEnum.values - .where((type) => type != ActivityTypeEnum.wordFocusListening) - .toList(), - clientTokenRequest: messageAnalyticsEntry!.nextActivityToken!, - clientTypeRequest: messageAnalyticsEntry!.nextActivityType!, + targetTokens: nextActivitySpecs.tokens, + targetType: nextActivitySpecs.activityType, ), widget.pangeaMessageEvent, ); @@ -269,15 +229,11 @@ class PracticeActivityCardState extends State { return; } - // update the target tokens with the new construct uses - // NOTE - multiple choice activity is handling adding these to analytics - - // 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 - messageAnalyticsEntry?.updateTargetTypesForMessage(); + widget.overlayController.messageAnalyticsEntry! + .onActivityComplete(currentActivity!); widget.overlayController.onActivityFinish(); + pangeaController.activityRecordController.completeActivity( widget.pangeaMessageEvent.eventId, ); @@ -305,7 +261,7 @@ class PracticeActivityCardState extends State { } void _onError() { - messageAnalyticsEntry?.revealAllTokens(); + widget.overlayController.messageAnalyticsEntry?.revealAllTokens(); _setPracticeActivity(null); } @@ -333,7 +289,7 @@ class PracticeActivityCardState extends State { } _fetchActivity( - ActivityQualityFeedback( + activityFeedback: ActivityQualityFeedback( feedbackText: feedback, badActivity: currentActivity!, ),