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
try {
await dotenv.load(fileName: ".env");
await dotenv.load(fileName: ".env.local_choreo");
} catch (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/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<ChatPageWithRoom>
// #Pangea
void showToolbar(
PangeaMessageEvent pangeaMessageEvent, {
PangeaToken? selectedToken,
MessageMode? mode,
Event? nextEvent,
Event? prevEvent,
@ -1692,6 +1694,7 @@ class ChatController extends State<ChatPageWithRoom>
chatController: this,
event: pangeaMessageEvent.event,
pangeaMessageEvent: pangeaMessageEvent,
selectedTokenOnInitialization: selectedToken,
nextEvent: nextEvent,
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/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

@ -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<TokenWithXP> tokensWithXp;
final PangeaMessageEvent pmEvent;
class TargetTokensAndActivityType {
final List<PangeaToken> 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<TokenWithXP> 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<PangeaToken> _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<TargetTokensAndActivityType> _activityQueue;
MessageAnalyticsEntry({
required List<PangeaToken> 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<int> 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<TargetTokensAndActivityType> setActivityQueue() {
final List<TargetTokensAndActivityType> 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<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,
// 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<PangeaToken> tokens) => PangeaToken.reconstructText(tokens);
MessageAnalyticsEntry? get(
PangeaMessageEvent pmEvent,
bool refresh,
List<PangeaToken> 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];
}
}

@ -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<PracticeActivityEvent?>();
// 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,
);

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

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

@ -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<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 {

@ -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<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
class ActivityQualityFeedback {
@ -235,102 +49,79 @@ class MessageActivityRequest {
final String userL2;
final String messageText;
final List<PangeaToken> messageTokens;
final ActivityQualityFeedback? activityQualityFeedback;
/// 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<PangeaToken> targetTokens;
final ActivityTypeEnum targetType;
final List<ActivityTypeEnum> 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<String, dynamic> 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<String, dynamic> json) {
return MessageActivityResponse(
activity: json['activity'] != null
? PracticeActivityModel.fromJson(
json['activity'] as Map<String, dynamic>,
)
: null,
finished: json['finished'] as bool,
existingActivityEventId: json['existing_activity_event_id'] as String?,
activity: PracticeActivityModel.fromJson(
json['activity'] as Map<String, dynamic>,
),
);
}
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(),
};
}
// 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 {
final List<ConstructIdentifier> 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<String, dynamic>))
.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;
}

@ -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 --------------------------

@ -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<MessageSelectionOverlay>
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;
@override
@ -113,30 +126,24 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
).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<void> _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<MessageSelectionOverlay>
)
.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<MessageSelectionOverlay>
setState(() {});
}
Future<void> setInitialToolbarMode() async {
Future<void> _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) {

@ -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<PangeaToken>? _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<TokenWithDisplayInstructions>? 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<PangeaToken>? 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<TokenPosition> 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,
});
}

@ -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(
//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,

@ -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<PracticeActivityCard> {
List<PracticeActivityEvent> 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<PracticeActivityCard> {
/// If not, get a new activity from the server.
Future<void> initialize() async {
_setPracticeActivity(
await _fetchActivity(),
await _fetchActivity(
selectedTargetTokenForWordMeaning:
widget.selectedTargetTokenForWordMeaning,
),
);
}
Future<PracticeActivityModel?> _fetchActivity([
Future<PracticeActivityModel?> _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<PracticeActivityCard> {
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<PracticeActivityCard> {
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<PracticeActivityCard> {
}
void _onError() {
messageAnalyticsEntry?.revealAllTokens();
widget.overlayController.messageAnalyticsEntry?.revealAllTokens();
_setPracticeActivity(null);
}
@ -333,7 +289,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
}
_fetchActivity(
ActivityQualityFeedback(
activityFeedback: ActivityQualityFeedback(
feedbackText: feedback,
badActivity: currentActivity!,
),

Loading…
Cancel
Save