Merge branch 'main' into 1014-overflow-when-audio-activity-opens

pull/1490/head
ggurdin 1 year ago committed by GitHub
commit d507f494e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -23,7 +23,7 @@ void main() async {
// #Pangea // #Pangea
try { try {
await dotenv.load(fileName: ".env"); await dotenv.load(fileName: ".env.local_choreo");
} catch (e) { } catch (e) {
Logs().e('Failed to load .env file', e); Logs().e('Failed to load .env file', e);
} }

@ -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/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart'; import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/choreo_record.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/representation_content_model.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart'; import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart'; import 'package:fluffychat/pangea/utils/error_handler.dart';
@ -1660,6 +1661,7 @@ class ChatController extends State<ChatPageWithRoom>
// #Pangea // #Pangea
void showToolbar( void showToolbar(
PangeaMessageEvent pangeaMessageEvent, { PangeaMessageEvent pangeaMessageEvent, {
PangeaToken? selectedToken,
MessageMode? mode, MessageMode? mode,
Event? nextEvent, Event? nextEvent,
Event? prevEvent, Event? prevEvent,
@ -1692,6 +1694,7 @@ class ChatController extends State<ChatPageWithRoom>
chatController: this, chatController: this,
event: pangeaMessageEvent.event, event: pangeaMessageEvent.event,
pangeaMessageEvent: pangeaMessageEvent, pangeaMessageEvent: pangeaMessageEvent,
selectedTokenOnInitialization: selectedToken,
nextEvent: nextEvent, nextEvent: nextEvent,
prevEvent: prevEvent, prevEvent: prevEvent,
); );

@ -4,7 +4,7 @@ import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/video_player.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/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.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/chat/message_toolbar_selection_area.dart';
import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart'; import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart'; import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
@ -306,25 +306,6 @@ class MessageContent extends StatelessWidget {
height: 1.3, 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) { if (immersionMode && pangeaMessageEvent != null) {
return Flexible( return Flexible(
child: PangeaRichText( 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# // Pangea#
return return

@ -2,8 +2,8 @@ import 'dart:math';
import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.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/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:flutter/foundation.dart';
/// Picks which tokens to do activities on and what types of activities to do /// 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 /// 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 /// 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 /// Otherwise, the user might leave the chat, return, and see a different word hidden
class MessageAnalyticsEntry {
final DateTime createdAt = DateTime.now();
late List<TokenWithXP> tokensWithXp;
final PangeaMessageEvent pmEvent; class TargetTokensAndActivityType {
final List<PangeaToken> tokens;
final ActivityTypeEnum activityType;
// TargetTokensAndActivityType({
bool isFirstTimeComputing = true; required this.tokens,
required this.activityType,
});
TokenWithXP? nextActivityToken; bool matchesActivity(PracticeActivityModel activity) {
ActivityTypeEnum? nextActivityType; // check if the existing activity has the same type as the target
if (activity.activityType != activityType) {
return false;
}
MessageAnalyticsEntry(this.pmEvent) { // check that the activity matches at least one construct in the target tokens
debugPrint('making MessageAnalyticsEntry: ${pmEvent.messageDisplayText}'); // TODO - this is complicated so we need to verify it works
if (pmEvent.messageDisplayRepresentation?.tokens == null) { // maybe we just verify that the target span of the activity is the same as the target span of the target
throw Exception('No tokens in message in MessageAnalyticsEntry'); 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<TokenWithXP> get tokensThatCanBeHeard => @override
tokensWithXp.where((t) => t.token.canBeHeard).toList(); bool operator ==(Object other) {
if (identical(this, other)) return true;
void updateTokenTargetTypes() { return other is TargetTokensAndActivityType &&
// compute target types for each token listEquals(other.tokens, tokens) &&
for (final token in tokensWithXp) { other.activityType == activityType;
token.targetTypes = []; }
if (!token.token.lemma.saveVocab) { @override
continue; int get hashCode => tokens.hashCode ^ activityType.hashCode;
} }
if (token.daysSinceLastUse < 1) { class MessageAnalyticsEntry {
continue; final DateTime createdAt = DateTime.now();
}
if (token.eligibleForActivity(ActivityTypeEnum.wordMeaning) && late final List<PangeaToken> _tokens;
!token.didActivity(ActivityTypeEnum.wordMeaning)) {
token.targetTypes.add(ActivityTypeEnum.wordMeaning);
}
if (token.eligibleForActivity(ActivityTypeEnum.wordFocusListening) && late final bool _includeHiddenWordActivities;
!token.didActivity(ActivityTypeEnum.wordFocusListening) &&
tokensThatCanBeHeard.length > 3) {
token.targetTypes.add(ActivityTypeEnum.wordFocusListening);
}
if (token.eligibleForActivity(ActivityTypeEnum.hiddenWordListening) && late final List<TargetTokensAndActivityType> _activityQueue;
isFirstTimeComputing &&
!token.didActivity(ActivityTypeEnum.hiddenWordListening) && MessageAnalyticsEntry({
!pmEvent.ownMessage) { required List<PangeaToken> tokens,
token.targetTypes.add(ActivityTypeEnum.hiddenWordListening); required bool includeHiddenWordActivities,
} }) {
} _tokens = tokens;
_includeHiddenWordActivities = includeHiddenWordActivities;
_activityQueue = setActivityQueue();
} }
/// Updates the target types for each token in the message and the next TargetTokensAndActivityType? get nextActivity =>
/// activity token and type. Called before requesting the next new activity. _activityQueue.isNotEmpty ? _activityQueue.first : null;
void updateTargetTypesForMessage() {
// reset bool get canDoWordFocusListening =>
nextActivityToken = null; _tokens.where((t) => t.canBeHeard).length > 4;
nextActivityType = null;
updateTokenTargetTypes(); /// On initialization, we pick which tokens to do activities on and what types of activities to do
List<TargetTokensAndActivityType> setActivityQueue() {
// From the tokens with hiddenWordListening in targetTypes, pick one at random. final List<TargetTokensAndActivityType> queue = [];
// Create a list of token indicies with hiddenWordListening type available.
final List<int> withHiddenWordIndices = tokensWithXp // for each token in the message
.asMap() // pick a random activity type from the eligible types
.entries for (final token in _tokens) {
.where( // get all the eligible activity types for the token
(entry) => entry.value.targetTypes.contains( // based on the context of the message
ActivityTypeEnum.hiddenWordListening, 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(); // if applicable, add a hidden word activity to the front of the queue
final hiddenWordActivity = getHiddenWordActivity(queue.length);
// randomly pick one index in the list and set the next activity if (hiddenWordActivity != null) {
if (withHiddenWordIndices.isNotEmpty) { queue.insert(0, hiddenWordActivity);
final int randomIndex = }
withHiddenWordIndices[Random().nextInt(withHiddenWordIndices.length)];
// limit to 3 activities
nextActivityToken = tokensWithXp[randomIndex]; return queue.take(3).toList();
nextActivityType = ActivityTypeEnum.hiddenWordListening; }
// remove hiddenWord type from all other tokens TargetTokensAndActivityType? getHiddenWordActivity(int numOtherActivities) {
// there can only be one hidden word activity for a message // don't do hidden word listening on own messages
for (int i = 0; i < tokensWithXp.length; i++) { if (!_includeHiddenWordActivities) {
if (i != randomIndex) { return null;
tokensWithXp[i] }
.targetTypes
.remove(ActivityTypeEnum.hiddenWordListening); // 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<List<PangeaToken>> sequences = [];
List<PangeaToken> 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, if (sequences.isEmpty) {
// pick the first token that has a target type return null;
nextActivityToken ??= }
tokensWithXp.where((t) => t.targetTypes.isNotEmpty).firstOrNull;
nextActivityType ??= nextActivityToken?.targetTypes.firstOrNull; 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() { void revealAllTokens() {
for (final token in tokensWithXp) { _activityQueue.removeWhere((a) => a.activityType.hiddenType);
token.targetTypes.remove(ActivityTypeEnum.hiddenWordListening);
}
} }
bool get shouldHideToken => tokensWithXp.any( bool isTokenInHiddenWordActivity(PangeaToken token) => _activityQueue.any(
(token) => (activity) =>
token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening), activity.tokens.contains(token) && activity.activityType.hiddenType,
); );
} }
@ -156,22 +207,25 @@ class MessageAnalyticsController {
} }
} }
String _key(List<PangeaToken> tokens) => PangeaToken.reconstructText(tokens);
MessageAnalyticsEntry? get( MessageAnalyticsEntry? get(
PangeaMessageEvent pmEvent, List<PangeaToken> tokens,
bool refresh, bool includeHiddenWordActivities,
) { ) {
if (pmEvent.messageDisplayRepresentation?.tokens == null) { final String key = _key(tokens);
return null;
}
if (_cache.containsKey(pmEvent.messageDisplayText) && !refresh) { if (_cache.containsKey(key)) {
return _cache[pmEvent.messageDisplayText]; return _cache[key];
} }
_cache[pmEvent.messageDisplayText] = MessageAnalyticsEntry(pmEvent); _cache[key] = MessageAnalyticsEntry(
tokens: tokens,
includeHiddenWordActivities: includeHiddenWordActivities,
);
clean(); clean();
return _cache[pmEvent.messageDisplayText]; return _cache[key];
} }
} }

@ -90,17 +90,10 @@ class PracticeGenerationController {
final response = MessageActivityResponse.fromJson(json); 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; return response;
} else { } else {
debugger(when: kDebugMode); 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, requestModel: req,
); );
if (res.finished) {
debugPrint('Activity generation finished');
return null;
}
final eventCompleter = Completer<PracticeActivityEvent?>(); final eventCompleter = Completer<PracticeActivityEvent?>();
// if the server points to an existing event, return that event debugPrint('Activity generated: ${res.activity.toJson()}');
if (res.existingActivityEventId != null) { _sendAndPackageEvent(res.activity, event).then((event) {
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) {
eventCompleter.complete(event); eventCompleter.complete(event);
}); });
final responseModel = PracticeActivityModelResponse( final responseModel = PracticeActivityModelResponse(
activity: res.activity!, activity: res.activity,
eventCompleter: eventCompleter, eventCompleter: eventCompleter,
); );

@ -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) { ActivityTypeEnum fromString(String value) {
final split = value.split('.').last; final split = value.split('.').last;
switch (split) { switch (split) {
@ -42,19 +52,19 @@ extension ActivityTypeExtension on ActivityTypeEnum {
return [ return [
ConstructUseTypeEnum.corPA, ConstructUseTypeEnum.corPA,
ConstructUseTypeEnum.incPA, ConstructUseTypeEnum.incPA,
ConstructUseTypeEnum.ignPA ConstructUseTypeEnum.ignPA,
]; ];
case ActivityTypeEnum.wordFocusListening: case ActivityTypeEnum.wordFocusListening:
return [ return [
ConstructUseTypeEnum.corWL, ConstructUseTypeEnum.corWL,
ConstructUseTypeEnum.incWL, ConstructUseTypeEnum.incWL,
ConstructUseTypeEnum.ignWL ConstructUseTypeEnum.ignWL,
]; ];
case ActivityTypeEnum.hiddenWordListening: case ActivityTypeEnum.hiddenWordListening:
return [ return [
ConstructUseTypeEnum.corHWL, ConstructUseTypeEnum.corHWL,
ConstructUseTypeEnum.incHWL, ConstructUseTypeEnum.incHWL,
ConstructUseTypeEnum.ignHWL ConstructUseTypeEnum.ignHWL,
]; ];
} }
} }

@ -2,7 +2,6 @@ import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart'; 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/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:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart'; import 'package:matrix/matrix.dart';
@ -76,11 +75,4 @@ class PracticeActivityEvent {
// DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs; // DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs;
String get parentMessageId => event.relationshipEventId!; String get parentMessageId => event.relationshipEventId!;
ExistingActivityMetaData get activityRequestMetaData =>
ExistingActivityMetaData(
activityEventId: event.eventId,
tgtConstructs: practiceActivity.tgtConstructs,
activityType: practiceActivity.activityType,
);
} }

@ -1,9 +1,13 @@
import 'dart:developer'; import 'dart:developer';
import 'package:collection/collection.dart'; 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_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_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/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 'package:flutter/foundation.dart';
import '../constants/model_keys.dart'; import '../constants/model_keys.dart';
@ -31,6 +35,18 @@ class PangeaToken {
required this.morph, 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 /// reconstructs the text from the tokens
/// [tokens] - the tokens to reconstruct /// [tokens] - the tokens to reconstruct
/// [debugWalkThrough] - if true, will start the debugger /// [debugWalkThrough] - if true, will start the debugger
@ -151,6 +167,186 @@ class PangeaToken {
category: pos, 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<ActivityTypeEnum> get eligibleActivityTypes {
final List<ActivityTypeEnum> 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<int>(
0,
(previousValue, element) => previousValue + element.points,
);
}
///
DateTime? get lastUsed => constructs.fold<DateTime?>(
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<ConstructIdentifier> get _constructIDs {
final List<ConstructIdentifier> 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<ConstructUses> get constructs => _constructIDs
.map(
(id) =>
MatrixState.pangeaController.getAnalytics.constructListModel
.getConstructUses(id) ??
ConstructUses(
lemma: id.lemma,
constructType: id.type,
category: id.category,
uses: [],
),
)
.toList();
Map<String, dynamic> toServerChoiceTokenWithXP() {
return {
'token': toJson(),
'constructs_with_xp': constructs.map((e) => e.toJson()).toList(),
'target_types': eligibleActivityTypes.map((e) => e.string).toList(),
};
}
} }
class PangeaTokenText { class PangeaTokenText {

@ -1,193 +1,7 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.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/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_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<ActivityTypeEnum> targetTypes;
TokenWithXP({
required this.token,
}) {
targetTypes = [];
}
List<ConstructIdentifier> get _constructIDs {
final List<ConstructIdentifier> 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<ConstructUses> 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<String, dynamic> json) {
return TokenWithXP(
token: PangeaToken.fromJson(json['token'] as Map<String, dynamic>),
);
}
Map<String, dynamic> 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<int>(
0,
(previousValue, element) => previousValue + element.points,
);
}
///
DateTime? get lastUsed => constructs.fold<DateTime?>(
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<ConstructIdentifier> tgtConstructs;
final ActivityTypeEnum activityType;
ExistingActivityMetaData({
required this.activityEventId,
required this.tgtConstructs,
required this.activityType,
});
factory ExistingActivityMetaData.fromJson(Map<String, dynamic> 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<String, dynamic>))
.toList(),
activityType: ActivityTypeEnum.values.firstWhere(
(element) =>
element.string == json['activity_type'] as String ||
element.string.split('.').last == json['activity_type'] as String,
),
);
}
Map<String, dynamic> 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 // includes feedback text and the bad activity model
class ActivityQualityFeedback { class ActivityQualityFeedback {
@ -235,102 +49,79 @@ class MessageActivityRequest {
final String userL2; final String userL2;
final String messageText; final String messageText;
final List<PangeaToken> messageTokens;
final ActivityQualityFeedback? activityQualityFeedback; final List<PangeaToken> targetTokens;
final ActivityTypeEnum targetType;
/// tokens with their associated constructs and xp
final List<TokenWithXP> tokensWithXP;
/// make the server aware of existing activities for potential reuse
final List<ExistingActivityMetaData> existingActivities;
final String messageId;
final List<ActivityTypeEnum> clientCompatibleActivities; final ActivityQualityFeedback? activityQualityFeedback;
final ActivityTypeEnum clientTypeRequest;
final TokenWithXP clientTokenRequest;
MessageActivityRequest({ MessageActivityRequest({
required this.userL1, required this.userL1,
required this.userL2, required this.userL2,
required this.messageText, required this.messageText,
required this.tokensWithXP, required this.messageTokens,
required this.messageId,
required this.existingActivities,
required this.activityQualityFeedback, required this.activityQualityFeedback,
required this.clientCompatibleActivities, required this.targetTokens,
required this.clientTokenRequest, required this.targetType,
required this.clientTypeRequest, }) {
}); 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<String, dynamic> toJson() { Map<String, dynamic> toJson() {
return { return {
'user_l1': userL1, 'user_l1': userL1,
'user_l2': userL2, 'user_l2': userL2,
'message_text': messageText, 'message_text': messageText,
'tokens_with_xp': tokensWithXP.map((e) => e.toJson()).toList(), 'message_tokens': messageTokens.map((e) => e.toJson()).toList(),
'message_id': messageId,
'existing_activities': existingActivities.map((e) => e.toJson()).toList(),
'activity_quality_feedback': activityQualityFeedback?.toJson(), 'activity_quality_feedback': activityQualityFeedback?.toJson(),
'iso_8601_time_of_req': DateTime.now().toIso8601String(), 'target_tokens': targetTokens.map((e) => e.toJson()).toList(),
// this is a list of activity types that the client can handle 'target_type': targetType.string,
// 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(),
}; };
} }
// equals accounts for message_id and last_used of each token
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (identical(this, other)) return true; if (identical(this, other)) return true;
return other is MessageActivityRequest && return other is MessageActivityRequest &&
other.messageId == messageId && other.messageText == messageText &&
const ListEquality().equals(other.tokensWithXP, tokensWithXP); other.targetType == targetType &&
other.activityQualityFeedback?.feedbackText ==
activityQualityFeedback?.feedbackText &&
const ListEquality().equals(other.targetTokens, targetTokens);
} }
@override @override
int get hashCode { int get hashCode {
return messageId.hashCode ^ return messageText.hashCode ^
const ListEquality().hash(tokensWithXP) ^ targetType.hashCode ^
activityQualityFeedback.hashCode; activityQualityFeedback.hashCode ^
targetTokens.hashCode;
} }
} }
class MessageActivityResponse { class MessageActivityResponse {
final PracticeActivityModel? activity; final PracticeActivityModel activity;
final bool finished;
final String? existingActivityEventId;
MessageActivityResponse({ MessageActivityResponse({
required this.activity, required this.activity,
required this.finished,
required this.existingActivityEventId,
}); });
factory MessageActivityResponse.fromJson(Map<String, dynamic> json) { factory MessageActivityResponse.fromJson(Map<String, dynamic> json) {
return MessageActivityResponse( return MessageActivityResponse(
activity: json['activity'] != null activity: PracticeActivityModel.fromJson(
? PracticeActivityModel.fromJson( json['activity'] as Map<String, dynamic>,
json['activity'] as Map<String, dynamic>, ),
)
: null,
finished: json['finished'] as bool,
existingActivityEventId: json['existing_activity_event_id'] as String?,
); );
} }
Map<String, dynamic> toJson() {
return {
'activity': activity?.toJson(),
'finished': finished,
'existing_activity_event_id': existingActivityEventId,
};
}
} }

@ -58,4 +58,20 @@ class ActivityContent {
'span_display_details': spanDisplayDetails?.toJson(), '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;
}
} }

@ -189,14 +189,12 @@ class PracticeActivityRequest {
class PracticeActivityModel { class PracticeActivityModel {
final List<ConstructIdentifier> tgtConstructs; final List<ConstructIdentifier> tgtConstructs;
final String langCode; final String langCode;
final String msgId;
final ActivityTypeEnum activityType; final ActivityTypeEnum activityType;
final ActivityContent content; final ActivityContent content;
PracticeActivityModel({ PracticeActivityModel({
required this.tgtConstructs, required this.tgtConstructs,
required this.langCode, required this.langCode,
required this.msgId,
required this.activityType, required this.activityType,
required this.content, required this.content,
}); });
@ -223,7 +221,6 @@ class PracticeActivityModel {
.map((e) => ConstructIdentifier.fromJson(e as Map<String, dynamic>)) .map((e) => ConstructIdentifier.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
langCode: json['lang_code'] as String, langCode: json['lang_code'] as String,
msgId: json['msg_id'] as String,
activityType: activityType:
ActivityTypeEnum.wordMeaning.fromString(json['activity_type']), ActivityTypeEnum.wordMeaning.fromString(json['activity_type']),
content: ActivityContent.fromJson(contentMap), content: ActivityContent.fromJson(contentMap),
@ -237,7 +234,6 @@ class PracticeActivityModel {
return { return {
'target_constructs': tgtConstructs.map((e) => e.toJson()).toList(), 'target_constructs': tgtConstructs.map((e) => e.toJson()).toList(),
'lang_code': langCode, 'lang_code': langCode,
'msg_id': msgId,
'activity_type': activityType.string, 'activity_type': activityType.string,
'content': content.toJson(), 'content': content.toJson(),
}; };
@ -251,7 +247,6 @@ class PracticeActivityModel {
return other is PracticeActivityModel && return other is PracticeActivityModel &&
const ListEquality().equals(other.tgtConstructs, tgtConstructs) && const ListEquality().equals(other.tgtConstructs, tgtConstructs) &&
other.langCode == langCode && other.langCode == langCode &&
other.msgId == msgId &&
other.activityType == activityType && other.activityType == activityType &&
other.content == content; other.content == content;
} }
@ -260,7 +255,6 @@ class PracticeActivityModel {
int get hashCode { int get hashCode {
return const ListEquality().hash(tgtConstructs) ^ return const ListEquality().hash(tgtConstructs) ^
langCode.hashCode ^ langCode.hashCode ^
msgId.hashCode ^
activityType.hashCode ^ activityType.hashCode ^
content.hashCode; content.hashCode;
} }

@ -59,7 +59,7 @@ class PApiUrls {
static String speechToText = "${PApiUrls.choreoEndpoint}/speech_to_text"; static String speechToText = "${PApiUrls.choreoEndpoint}/speech_to_text";
static String messageActivityGeneration = static String messageActivityGeneration =
"${PApiUrls.choreoEndpoint}/practice/message"; "${PApiUrls.choreoEndpoint}/practice";
///-------------------------------- revenue cat -------------------------- ///-------------------------------- revenue cat --------------------------

@ -6,13 +6,14 @@ import 'package:fluffychat/config/setting_keys.dart';
import 'package:fluffychat/config/themes.dart'; import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat/chat.dart'; import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/message_reactions.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_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/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.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/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_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/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.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.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_footer.dart';
@ -28,24 +29,25 @@ import 'package:matrix/matrix.dart';
class MessageSelectionOverlay extends StatefulWidget { class MessageSelectionOverlay extends StatefulWidget {
final ChatController chatController; final ChatController chatController;
late final Event _event; final Event _event;
late final Event? _nextEvent; final Event? _nextEvent;
late final Event? _prevEvent; final Event? _prevEvent;
late final PangeaMessageEvent _pangeaMessageEvent; final PangeaMessageEvent _pangeaMessageEvent;
final PangeaToken? _selectedTokenOnInitialization;
MessageSelectionOverlay({ const MessageSelectionOverlay({
required this.chatController, required this.chatController,
required Event event, required Event event,
required PangeaMessageEvent pangeaMessageEvent, required PangeaMessageEvent pangeaMessageEvent,
required PangeaToken? selectedTokenOnInitialization,
required Event? nextEvent, required Event? nextEvent,
required Event? prevEvent, required Event? prevEvent,
super.key, super.key,
}) { }) : _selectedTokenOnInitialization = selectedTokenOnInitialization,
_pangeaMessageEvent = pangeaMessageEvent; _pangeaMessageEvent = pangeaMessageEvent,
_nextEvent = nextEvent; _nextEvent = nextEvent,
_prevEvent = prevEvent; _prevEvent = prevEvent,
_event = event; _event = event;
}
@override @override
MessageOverlayController createState() => MessageOverlayController(); MessageOverlayController createState() => MessageOverlayController();
@ -76,6 +78,17 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
bool get showToolbarButtons => !widget._pangeaMessageEvent.isAudioMessage; 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<PangeaToken>? tokens; List<PangeaToken>? tokens;
@override @override
@ -113,30 +126,24 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
).listen((_) => setState(() {})); ).listen((_) => setState(() {}));
tts.setupTTS(); tts.setupTTS();
setInitialToolbarMode();
_setInitialToolbarModeAndSelectedSpan();
} }
MessageTokenText get messageTokenText => MessageTokenText( MessageAnalyticsEntry? get messageAnalyticsEntry => tokens != null
ownMessage: pangeaMessageEvent.ownMessage, ? MatrixState.pangeaController.getAnalytics.perMessage.get(
fullText: pangeaMessageEvent.messageDisplayText, tokens!,
tokensWithDisplay: tokens // this logic should be in the controller
?.map( !pangeaMessageEvent.ownMessage &&
(token) => TokenWithDisplayInstructions( pangeaMessageEvent.messageDisplayRepresentation?.tokens != null,
token: token, )
highlight: isTokenSelected(token), : null;
//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<void> _getTokens() async { Future<void> _getTokens() async {
tokens = pangeaMessageEvent.originalSent?.tokens; tokens = pangeaMessageEvent.originalSent?.tokens;
if (pangeaMessageEvent.originalSent != null && tokens == null) { if (pangeaMessageEvent.originalSent != null && tokens == null) {
debugPrint("fetching tokens");
pangeaMessageEvent.originalSent! pangeaMessageEvent.originalSent!
.tokensGlobal( .tokensGlobal(
pangeaMessageEvent.senderId, pangeaMessageEvent.senderId,
@ -144,7 +151,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
) )
.then((tokens) { .then((tokens) {
// this isn't currently working because originalSent's _event is null // 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<MessageSelectionOverlay>
setState(() {}); setState(() {});
} }
Future<void> setInitialToolbarMode() async { Future<void> _setInitialToolbarModeAndSelectedSpan() async {
debugPrint(
"setting initial toolbar mode and selected span with tokens $tokens",
);
if (widget._pangeaMessageEvent.isAudioMessage) { if (widget._pangeaMessageEvent.isAudioMessage) {
toolbarMode = MessageMode.speechToText; toolbarMode = MessageMode.speechToText;
return; return setState(() => toolbarMode = MessageMode.practiceActivity);
} }
// if (!messageInUserL2) {
// activitiesLeftToComplete = 0; // we're only going to do activities if we have tokens for the message
// toolbarMode = MessageMode.nullMode; if (tokens != null) {
// return; // if the user selects a span on initialization, then we want to give
// } // them a practice activity on that word
if (selectedTargetTokenForWordMeaning != null) {
if (activitiesLeftToComplete > 0) { _selectedSpan = selectedTargetTokenForWordMeaning?.text;
toolbarMode = MessageMode.practiceActivity; return setState(() => toolbarMode = MessageMode.practiceActivity);
return; }
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 if (MatrixState.pangeaController.userController.profile.userSettings
.autoPlayMessages) { .autoPlayMessages) {
toolbarMode = MessageMode.textToSpeech; return setState(() => toolbarMode = MessageMode.textToSpeech);
return;
} }
toolbarMode = MessageMode.translation; setState(() => toolbarMode = MessageMode.translation);
setState(() {});
} }
updateToolbarMode(MessageMode mode) { updateToolbarMode(MessageMode mode) {

@ -1,82 +1,101 @@
import 'package:fluffychat/config/app_config.dart'; import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.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/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/widgets/matrix.dart'; import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// Question - does this need to be stateful or does this work?
/// Need to test.
class MessageTokenText extends StatelessWidget { class MessageTokenText extends StatelessWidget {
final PangeaController pangeaController = MatrixState.pangeaController; final PangeaMessageEvent _pangeaMessageEvent;
final bool ownMessage; final List<PangeaToken>? _tokens;
/// this must match the tokens or we've got problems final TextStyle _style;
final String fullText;
/// this must match the fullText or we've got problems final bool Function(PangeaToken)? _isSelected;
final List<TokenWithDisplayInstructions>? tokensWithDisplay; final void Function(PangeaToken)? _onClick;
final void Function(PangeaToken)? onClick;
MessageTokenText({ const MessageTokenText({
super.key, super.key,
required this.ownMessage, required PangeaMessageEvent pangeaMessageEvent,
required this.fullText, required List<PangeaToken>? tokens,
required this.tokensWithDisplay, required TextStyle style,
required this.onClick, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final style = TextStyle( if (_tokens == null) {
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( return Text(
fullText, _pangeaMessageEvent.messageDisplayText,
style: style, style: _style,
); );
} }
// Convert the entire message into a list of characters // 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 // When building token positions, use grapheme cluster indices
final List<TokenPosition> tokenPositions = []; final List<TokenPosition> tokenPositions = [];
int globalIndex = 0; int globalIndex = 0;
for (int i = 0; i < tokensWithDisplay!.length; i++) { for (final token
final tokenWithDisplay = tokensWithDisplay![i]; in _pangeaMessageEvent.messageDisplayRepresentation!.tokens!) {
final start = tokenWithDisplay.token.start; final start = token.start;
final end = tokenWithDisplay.token.end; final end = token.end;
// Calculate the number of grapheme clusters up to the start and end positions // Calculate the number of grapheme clusters up to the start and end positions
final int startIndex = messageCharacters.take(start).length; final int startIndex = messageCharacters.take(start).length;
final int endIndex = messageCharacters.take(end).length; final int endIndex = messageCharacters.take(end).length;
final hideContent =
messageAnalyticsEntry?.isTokenInHiddenWordActivity(token) ?? false;
if (globalIndex < startIndex) { 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( tokenPositions.add(
TokenPosition( TokenPosition(
start: startIndex, start: startIndex,
end: endIndex, end: endIndex,
tokenIndex: i, token: token,
token: tokenWithDisplay, hideContent: hideContent,
highlight: (_isSelected?.call(token) ?? false) && !hideContent,
), ),
); );
globalIndex = endIndex; globalIndex = endIndex;
} }
//TODO - take out of build function of every message
return RichText( return RichText(
text: TextSpan( text: TextSpan(
children: tokenPositions.map((tokenPosition) { children:
tokenPositions.mapIndexed((int i, TokenPosition tokenPosition) {
final substring = messageCharacters final substring = messageCharacters
.skip(tokenPosition.start) .skip(tokenPosition.start)
.take(tokenPosition.end - tokenPosition.start) .take(tokenPosition.end - tokenPosition.start)
@ -85,13 +104,15 @@ class MessageTokenText extends StatelessWidget {
if (tokenPosition.token != null) { if (tokenPosition.token != null) {
return TextSpan( return TextSpan(
recognizer: TapGestureRecognizer() recognizer: TapGestureRecognizer()
..onTap = () => onClick != null ..onTap = () => _onClick != null && tokenPosition.token != null
? onClick!(tokenPosition.token!.token) ? _onClick!(tokenPosition.token!)
: null, : null,
text: !tokenPosition.token!.hideContent ? substring : '_____', text: !tokenPosition.hideContent
style: style.merge( ? substring
: '_' * substring.length,
style: _style.merge(
TextStyle( TextStyle(
backgroundColor: tokenPosition.token!.highlight backgroundColor: tokenPosition.highlight
? Theme.of(context).brightness == Brightness.light ? Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(0.4) ? Colors.black.withOpacity(0.4)
: Colors.white.withOpacity(0.4) : Colors.white.withOpacity(0.4)
@ -101,8 +122,12 @@ class MessageTokenText extends StatelessWidget {
); );
} else { } else {
return TextSpan( return TextSpan(
text: substring, text: (i > 0 || i < tokenPositions.length - 1) &&
style: style, tokenPositions[i + 1].hideContent &&
tokenPositions[i - 1].hideContent
? '_' * substring.length
: substring,
style: _style,
); );
} }
}).toList(), }).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 { class TokenPosition {
final int start; final int start;
final int end; final int end;
final TokenWithDisplayInstructions? token; final bool highlight;
final int tokenIndex; final bool hideContent;
final PangeaToken? token;
const TokenPosition({ const TokenPosition({
required this.start, required this.start,
required this.end, required this.end,
required this.hideContent,
required this.highlight,
this.token, this.token,
this.tokenIndex = -1,
}); });
} }

@ -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<TokenPosition> 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,
});
}

@ -72,7 +72,8 @@ class MessageToolbar extends StatelessWidget {
return FutureBuilder( return FutureBuilder(
//TODO - convert this to synchronous if possible //TODO - convert this to synchronous if possible
future: Future.value( future: Future.value(
pangeaMessageEvent.messageDisplayRepresentation?.tokens), pangeaMessageEvent.messageDisplayRepresentation?.tokens,
),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) { if (snapshot.connectionState != ConnectionState.done) {
return const ToolbarContentLoadingIndicator(); return const ToolbarContentLoadingIndicator();
@ -127,6 +128,8 @@ class MessageToolbar extends StatelessWidget {
); );
} }
return PracticeActivityCard( return PracticeActivityCard(
selectedTargetTokenForWordMeaning:
overLayController.selectedTargetTokenForWordMeaning,
pangeaMessageEvent: pangeaMessageEvent, pangeaMessageEvent: pangeaMessageEvent,
overlayController: overLayController, overlayController: overLayController,
ttsController: ttsController, ttsController: ttsController,

@ -1,6 +1,7 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart'; import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart'; import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/practice_activity_generation_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/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_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/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/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_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_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 PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController; final MessageOverlayController overlayController;
final TtsController ttsController; final TtsController ttsController;
final PangeaToken? selectedTargetTokenForWordMeaning;
const PracticeActivityCard({ const PracticeActivityCard({
super.key, super.key,
required this.pangeaMessageEvent, required this.pangeaMessageEvent,
required this.overlayController, required this.overlayController,
required this.ttsController, required this.ttsController,
required this.selectedTargetTokenForWordMeaning,
}); });
@override @override
@ -53,31 +57,6 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
List<PracticeActivityEvent> get practiceActivities => List<PracticeActivityEvent> get practiceActivities =>
widget.pangeaMessageEvent.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 // Used to show an animation when the user completes an activity
// while simultaneously fetching a new activity and not showing the loading spinner // while simultaneously fetching a new activity and not showing the loading spinner
// until the appropriate time has passed to 'savor the joy' // until the appropriate time has passed to 'savor the joy'
@ -118,72 +97,63 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
/// If not, get a new activity from the server. /// If not, get a new activity from the server.
Future<void> initialize() async { Future<void> initialize() async {
_setPracticeActivity( _setPracticeActivity(
await _fetchActivity(), await _fetchActivity(
selectedTargetTokenForWordMeaning:
widget.selectedTargetTokenForWordMeaning,
),
); );
} }
Future<PracticeActivityModel?> _fetchActivity([ Future<PracticeActivityModel?> _fetchActivity({
ActivityQualityFeedback? activityFeedback, ActivityQualityFeedback? activityFeedback,
]) async { PangeaToken? selectedTargetTokenForWordMeaning,
// temporary }) async {
// try { // try {
debugPrint('Fetching activity'); debugPrint('Fetching activity');
// debugger();
_updateFetchingActivity(true); _updateFetchingActivity(true);
// target tokens can be empty if activities have been completed for each // 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 // it's set on initialization and then removed when each activity is completed
if (!pangeaController.languageController.languagesSet) { if (!mounted ||
debugger(when: kDebugMode); !pangeaController.languageController.languagesSet ||
_updateFetchingActivity(false); widget.overlayController.messageAnalyticsEntry == null) {
return null;
}
if (!mounted) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
if (widget.pangeaMessageEvent.messageDisplayRepresentation == null) {
debugger(when: kDebugMode); debugger(when: kDebugMode);
_updateFetchingActivity(false); _updateFetchingActivity(false);
ErrorHandler.logError(
e: Exception('No original message found in _fetchNewActivity'),
data: {
'event': widget.pangeaMessageEvent.event.toJson(),
},
);
return null; return null;
} }
if (widget.pangeaMessageEvent.messageDisplayRepresentation?.tokens == // if the user selected a token which is not already in a hidden word activity,
null) { // we're going to give them an activity on that token first
debugger(when: kDebugMode); // otherwise, we're going to give them an activity on the next token in the queue
_updateFetchingActivity(false); final TargetTokensAndActivityType? nextActivitySpecs =
return null; selectedTargetTokenForWordMeaning != null
} ? TargetTokensAndActivityType(
tokens: [selectedTargetTokenForWordMeaning],
activityType: ActivityTypeEnum.wordMeaning,
)
: widget.overlayController.messageAnalyticsEntry?.nextActivity;
// the client is going to be choosing the next activity now // the client is going to be choosing the next activity now
// if nothing is set then it must be done with practice // if nothing is set then it must be done with practice
if (messageAnalyticsEntry?.nextActivityToken == null || if (nextActivitySpecs == null) {
messageAnalyticsEntry?.nextActivityType == null) { debugPrint("No next activity set, exiting practice flow");
debugger(when: kDebugMode);
_updateFetchingActivity(false); _updateFetchingActivity(false);
return null; return null;
} }
final existingActivity = existingActivityMatchingNeeds; final existingActivity = practiceActivities.firstWhereOrNull(
(activity) =>
nextActivitySpecs.matchesActivity(activity.practiceActivity),
);
if (existingActivity != null) { if (existingActivity != null) {
debugPrint('found existing activity');
_updateFetchingActivity(false);
return existingActivity.practiceActivity; return existingActivity.practiceActivity;
} }
debugPrint( debugPrint(
"client requesting activity type: ${messageAnalyticsEntry?.nextActivityType}", "client requesting ${nextActivitySpecs.activityType.string} for ${nextActivitySpecs.tokens.map((t) => t.text).join(' ')}",
);
debugPrint(
"client requesting token: ${messageAnalyticsEntry?.nextActivityToken?.token.text.content}",
); );
final PracticeActivityModelResponse? activityResponse = final PracticeActivityModelResponse? activityResponse =
@ -192,20 +162,10 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
userL1: pangeaController.languageController.userL1!.langCode, userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode, userL2: pangeaController.languageController.userL2!.langCode,
messageText: widget.pangeaMessageEvent.originalSent!.text, messageText: widget.pangeaMessageEvent.originalSent!.text,
tokensWithXP: messageAnalyticsEntry!.tokensWithXp, messageTokens: widget.overlayController.tokens!,
messageId: widget.pangeaMessageEvent.eventId,
existingActivities: practiceActivities
.map((activity) => activity.activityRequestMetaData)
.toList(),
activityQualityFeedback: activityFeedback, activityQualityFeedback: activityFeedback,
clientCompatibleActivities: widget targetTokens: nextActivitySpecs.tokens,
.ttsController.isLanguageFullySupported targetType: nextActivitySpecs.activityType,
? ActivityTypeEnum.values
: ActivityTypeEnum.values
.where((type) => type != ActivityTypeEnum.wordFocusListening)
.toList(),
clientTokenRequest: messageAnalyticsEntry!.nextActivityToken!,
clientTypeRequest: messageAnalyticsEntry!.nextActivityType!,
), ),
widget.pangeaMessageEvent, widget.pangeaMessageEvent,
); );
@ -269,15 +229,11 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
return; return;
} }
// update the target tokens with the new construct uses widget.overlayController.messageAnalyticsEntry!
// NOTE - multiple choice activity is handling adding these to analytics .onActivityComplete(currentActivity!);
// 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.onActivityFinish(); widget.overlayController.onActivityFinish();
pangeaController.activityRecordController.completeActivity( pangeaController.activityRecordController.completeActivity(
widget.pangeaMessageEvent.eventId, widget.pangeaMessageEvent.eventId,
); );
@ -305,7 +261,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
} }
void _onError() { void _onError() {
messageAnalyticsEntry?.revealAllTokens(); widget.overlayController.messageAnalyticsEntry?.revealAllTokens();
_setPracticeActivity(null); _setPracticeActivity(null);
} }
@ -333,7 +289,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
} }
_fetchActivity( _fetchActivity(
ActivityQualityFeedback( activityFeedback: ActivityQualityFeedback(
feedbackText: feedback, feedbackText: feedback,
badActivity: currentActivity!, badActivity: currentActivity!,
), ),

Loading…
Cancel
Save