resolve merge conflicts

pull/1490/head
ggurdin 1 year ago
commit 9d49a5542d
No known key found for this signature in database
GPG Key ID: A01CB41737CBB478

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

@ -4,8 +4,8 @@ import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/video_player.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_token_text_stateful.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_selection_area.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_message_text.dart';
import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
@ -306,13 +306,23 @@ class MessageContent extends StatelessWidget {
height: 1.3,
);
if (overlayController != null && pangeaMessageEvent != null) {
return OverlayMessageText(
pangeaMessageEvent: pangeaMessageEvent!,
overlayController: overlayController!,
if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens !=
null) {
return MessageTokenTextStateful(
messageAnalyticsEntry:
controller.pangeaController.getAnalytics.perMessage.get(
pangeaMessageEvent!,
false,
)!,
style: messageTextStyle,
onClick: (token) => controller.showToolbar(pangeaMessageEvent!),
);
}
if (overlayController != null && pangeaMessageEvent != null) {
return overlayController!.messageTokenText;
}
if (immersionMode && pangeaMessageEvent != null) {
return Flexible(
child: PangeaRichText(

@ -3,6 +3,7 @@ import 'dart:math';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
@ -19,16 +20,21 @@ import 'package:sentry_flutter/sentry_flutter.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics
class GetAnalyticsController {
late PangeaController _pangeaController;
late MessageAnalyticsController perMessage;
final List<AnalyticsCacheEntry> _cache = [];
StreamSubscription<AnalyticsUpdate>? _analyticsUpdateSubscription;
StreamController<AnalyticsStreamUpdate> analyticsStream =
StreamController.broadcast();
ConstructListModel constructListModel = ConstructListModel(uses: []);
Completer<void>? initCompleter;
Completer<void> initCompleter = Completer<void>();
GetAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
perMessage = MessageAnalyticsController(
this,
);
}
String? get _l2Code => _pangeaController.languageController.userL2?.langCode;
@ -58,22 +64,25 @@ class GetAnalyticsController {
}
Future<void> initialize() async {
if (initCompleter != null) return;
initCompleter = Completer<void>();
_analyticsUpdateSubscription ??= _pangeaController
.putAnalytics.analyticsUpdateStream.stream
.listen(_onAnalyticsUpdate);
if (initCompleter.isCompleted) return;
await _pangeaController.putAnalytics.lastUpdatedCompleter.future;
await _getConstructs();
constructListModel.updateConstructs([
...(_getConstructsLocal() ?? []),
..._locallyCachedConstructs,
]);
_updateAnalyticsStream();
initCompleter!.complete();
try {
_analyticsUpdateSubscription ??= _pangeaController
.putAnalytics.analyticsUpdateStream.stream
.listen(_onAnalyticsUpdate);
await _pangeaController.putAnalytics.lastUpdatedCompleter.future;
await _getConstructs();
constructListModel.updateConstructs([
...(_getConstructsLocal() ?? []),
..._locallyCachedConstructs,
]);
_updateAnalyticsStream();
} catch (err, s) {
ErrorHandler.logError(e: err, s: s);
} finally {
if (!initCompleter.isCompleted) initCompleter.complete();
}
}
/// Clear all cached analytics data.
@ -81,8 +90,9 @@ class GetAnalyticsController {
constructListModel.dispose();
_analyticsUpdateSubscription?.cancel();
_analyticsUpdateSubscription = null;
initCompleter = null;
initCompleter = Completer<void>();
_cache.clear();
// perMessage.dispose();
}
Future<void> _onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async {

@ -0,0 +1,169 @@
import 'dart:math';
import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:flutter/foundation.dart';
/// Picks which tokens to do activities on and what types of activities to do
/// Caches result so that we don't have to recompute it
/// Most importantly, we can't do this in the state of a message widget because the state is disposed of and recreated
/// If we decided that the first token should have a hidden word listening, we need to remember that
/// Otherwise, the user might leave the chat, return, and see a different word hidden
class MessageAnalyticsEntry {
final DateTime createdAt = DateTime.now();
late List<TokenWithXP> tokensWithXp;
final PangeaMessageEvent pmEvent;
//
bool isFirstTimeComputing = true;
TokenWithXP? nextActivityToken;
ActivityTypeEnum? nextActivityType;
MessageAnalyticsEntry(this.pmEvent) {
debugPrint('making MessageAnalyticsEntry: ${pmEvent.messageDisplayText}');
if (pmEvent.messageDisplayRepresentation?.tokens == null) {
throw Exception('No tokens in message in MessageAnalyticsEntry');
}
tokensWithXp = pmEvent.messageDisplayRepresentation!.tokens!
.map((token) => TokenWithXP(token: token))
.toList();
computeTargetTypesForMessage();
}
List<TokenWithXP> get tokensThatCanBeHeard =>
tokensWithXp.where((t) => t.token.canBeHeard).toList();
// compute target tokens within async wrapper that adds a 250ms delay
// to avoid blocking the UI thread
Future<void> computeTargetTypesForMessageAsync() async {
await Future.delayed(const Duration(milliseconds: 250));
computeTargetTypesForMessage();
}
void computeTargetTypesForMessage() {
// reset
nextActivityToken = null;
nextActivityType = null;
// compute target types for each token
for (final token in tokensWithXp) {
token.targetTypes = [];
if (!token.token.lemma.saveVocab) {
continue;
}
if (token.daysSinceLastUse < 1) {
continue;
}
if (token.eligibleForActivity(ActivityTypeEnum.wordMeaning) &&
!token.didActivity(ActivityTypeEnum.wordMeaning)) {
token.targetTypes.add(ActivityTypeEnum.wordMeaning);
}
if (token.eligibleForActivity(ActivityTypeEnum.wordFocusListening) &&
!token.didActivity(ActivityTypeEnum.wordFocusListening) &&
tokensThatCanBeHeard.length > 3) {
token.targetTypes.add(ActivityTypeEnum.wordFocusListening);
}
if (token.eligibleForActivity(ActivityTypeEnum.hiddenWordListening) &&
isFirstTimeComputing &&
!token.didActivity(ActivityTypeEnum.hiddenWordListening) &&
!pmEvent.ownMessage) {
token.targetTypes.add(ActivityTypeEnum.hiddenWordListening);
}
}
// from the tokens with hiddenWordListening in targetTypes, pick one at random
final List<int> withListening = tokensWithXp
.asMap()
.entries
.where(
(entry) => entry.value.targetTypes
.contains(ActivityTypeEnum.hiddenWordListening),
)
.map((entry) => entry.key)
.toList();
// randomly pick one entry in the list
if (withListening.isNotEmpty) {
final int randomIndex =
withListening[Random().nextInt(withListening.length)];
nextActivityToken = tokensWithXp[randomIndex];
nextActivityType = ActivityTypeEnum.hiddenWordListening;
// remove from all other tokens
for (int i = 0; i < tokensWithXp.length; i++) {
if (i != randomIndex) {
tokensWithXp[i]
.targetTypes
.remove(ActivityTypeEnum.hiddenWordListening);
}
}
}
// if we didn't find any hiddenWordListening,
// pick the first token that has a target type
nextActivityToken ??=
tokensWithXp.where((t) => t.targetTypes.isNotEmpty).firstOrNull;
nextActivityType ??= nextActivityToken?.targetTypes.firstOrNull;
isFirstTimeComputing = false;
}
bool get shouldHideToken => tokensWithXp.any(
(token) =>
token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening),
);
}
/// computes TokenWithXP for given a pangeaMessageEvent and caches the result, according to the full text of the message
/// listens for analytics updates and updates the cache accordingly
class MessageAnalyticsController {
final GetAnalyticsController getAnalytics;
final Map<String, MessageAnalyticsEntry> _cache = {};
MessageAnalyticsController(this.getAnalytics);
void dispose() {
_cache.clear();
}
// if over 50, remove oldest 5 entries by createdAt
void clean() {
if (_cache.length > 50) {
final sortedEntries = _cache.entries.toList()
..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
for (var i = 0; i < 5; i++) {
_cache.remove(sortedEntries[i].key);
}
}
}
MessageAnalyticsEntry? get(
PangeaMessageEvent pmEvent,
bool refresh,
) {
if (pmEvent.messageDisplayRepresentation?.tokens == null) {
return null;
}
if (_cache.containsKey(pmEvent.messageDisplayText) && !refresh) {
return _cache[pmEvent.messageDisplayText];
}
_cache[pmEvent.messageDisplayText] = MessageAnalyticsEntry(pmEvent);
clean();
return _cache[pmEvent.messageDisplayText];
}
}

@ -90,6 +90,13 @@ class PracticeGenerationController {
final response = MessageActivityResponse.fromJson(json);
// workaround for the server not returning the tgtConstructs
// if (response.activity != null &&
// response.activity!.tgtConstructs.isEmpty) {
// response.activity?.tgtConstructs.addAll(
// requestModel.clientTokenRequest.constructIDs,
// );
// }
return response;
} else {
debugger(when: kDebugMode);
@ -105,6 +112,8 @@ class PracticeGenerationController {
) async {
final int cacheKey = req.hashCode;
// debugger(when: kDebugMode);
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey]!.practiceActivity;
}

@ -174,7 +174,7 @@ class PutAnalyticsController extends BaseController<AnalyticsStream> {
(token) => OneConstructUse(
useType: useType,
lemma: token.lemma.text,
form: token.lemma.form,
form: token.text.content,
constructType: ConstructTypeEnum.vocab,
metadata: metadata,
category: token.pos,

@ -1,12 +1,61 @@
enum ActivityTypeEnum { multipleChoice, wordFocusListening }
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
enum ActivityTypeEnum { wordMeaning, wordFocusListening, hiddenWordListening }
extension ActivityTypeExtension on ActivityTypeEnum {
String get string {
switch (this) {
case ActivityTypeEnum.multipleChoice:
return 'multiple_choice';
case ActivityTypeEnum.wordMeaning:
return 'word_meaning';
case ActivityTypeEnum.wordFocusListening:
return 'word_focus_listening';
case ActivityTypeEnum.hiddenWordListening:
return 'hidden_word_listening';
}
}
ActivityTypeEnum fromString(String value) {
final split = value.split('.').last;
switch (split) {
// used to be called multiple_choice, but we changed it to word_meaning
// as we now have multiple types of multiple choice activities
// old data will still have multiple_choice so we need to handle that
case 'multiple_choice':
case 'multipleChoice':
case 'word_meaning':
case 'wordMeaning':
return ActivityTypeEnum.wordMeaning;
case 'word_focus_listening':
case 'wordFocusListening':
return ActivityTypeEnum.wordFocusListening;
case 'hidden_word_listening':
case 'hiddenWordListening':
return ActivityTypeEnum.hiddenWordListening;
default:
throw Exception('Unknown activity type: $split');
}
}
List<ConstructUseTypeEnum> get associatedUseTypes {
switch (this) {
case ActivityTypeEnum.wordMeaning:
return [
ConstructUseTypeEnum.corPA,
ConstructUseTypeEnum.incPA,
ConstructUseTypeEnum.ignPA
];
case ActivityTypeEnum.wordFocusListening:
return [
ConstructUseTypeEnum.corWL,
ConstructUseTypeEnum.incWL,
ConstructUseTypeEnum.ignWL
];
case ActivityTypeEnum.hiddenWordListening:
return [
ConstructUseTypeEnum.corHWL,
ConstructUseTypeEnum.incHWL,
ConstructUseTypeEnum.ignHWL
];
}
}
}

@ -42,12 +42,21 @@ enum ConstructUseTypeEnum {
/// was target lemma in word-focus listening activity and correctly selected
corWL,
/// form of lemma was read-aloud in word-focus listening activity and incorrectly selected
/// a form of lemma was read-aloud in word-focus listening activity and incorrectly selected
incWL,
/// form of lemma was read-aloud in word-focus listening activity and correctly ignored
/// a form of the lemma was read-aloud in word-focus listening activity and correctly ignored
ignWL,
/// correctly chose a form of the lemma in a hidden word listening activity
corHWL,
/// incorrectly chose a form of the lemma in a hidden word listening activity
incHWL,
/// ignored a form of the lemma in a hidden word listening activity
ignHWL,
/// not defined, likely a new construct introduced by choreo and not yet classified by an old version of the client
nan
}
@ -71,12 +80,15 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.ignPA:
case ConstructUseTypeEnum.ignWL:
case ConstructUseTypeEnum.incWL:
case ConstructUseTypeEnum.incHWL:
case ConstructUseTypeEnum.ignHWL:
return Icons.close;
case ConstructUseTypeEnum.ga:
case ConstructUseTypeEnum.corIGC:
case ConstructUseTypeEnum.corPA:
case ConstructUseTypeEnum.corWL:
case ConstructUseTypeEnum.corHWL:
return Icons.check;
case ConstructUseTypeEnum.unk:
@ -98,6 +110,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.wa:
case ConstructUseTypeEnum.corWL:
case ConstructUseTypeEnum.corHWL:
return 3;
case ConstructUseTypeEnum.corIGC:
@ -110,6 +123,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.ignIGC:
case ConstructUseTypeEnum.ignPA:
case ConstructUseTypeEnum.ignWL:
case ConstructUseTypeEnum.ignHWL:
case ConstructUseTypeEnum.unk:
case ConstructUseTypeEnum.nan:
return 0;
@ -123,6 +137,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incPA:
case ConstructUseTypeEnum.incWL:
case ConstructUseTypeEnum.incHWL:
return -3;
}
}

@ -1,7 +1,7 @@
import 'dart:math';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
@ -65,6 +65,7 @@ class ConstructListModel {
category: use.category,
);
currentUses.uses.add(use);
currentUses.setLastUsed(use.timeStamp);
_constructMap[use.identifier.string] = currentUses;
}
}
@ -149,38 +150,3 @@ class ConstructListModel {
);
}
}
/// One lemma and a list of construct uses for that lemma
class ConstructUses {
final List<OneConstructUse> uses;
final ConstructTypeEnum constructType;
final String lemma;
final String? _category;
ConstructUses({
required this.uses,
required this.constructType,
required this.lemma,
required category,
}) : _category = category;
// Total points for all uses of this lemma
int get points {
return uses.fold<int>(
0,
(total, use) => total + use.useType.pointValue,
);
}
DateTime? _lastUsed;
DateTime? get lastUsed {
if (_lastUsed != null) return _lastUsed;
final lastUse = uses.fold<DateTime?>(null, (DateTime? last, use) {
if (last == null) return use.timeStamp;
return use.timeStamp.isAfter(last) ? use.timeStamp : last;
});
return _lastUsed = lastUse;
}
String get category => _category ?? "Other";
}

@ -0,0 +1,61 @@
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
/// One lemma and a list of construct uses for that lemma
class ConstructUses {
final List<OneConstructUse> uses;
final ConstructTypeEnum constructType;
final String lemma;
final String? _category;
DateTime? _lastUsed;
ConstructUses({
required this.uses,
required this.constructType,
required this.lemma,
required category,
}) : _category = category;
// Total points for all uses of this lemma
int get points {
return uses.fold<int>(
0,
(total, use) => total + use.useType.pointValue,
);
}
DateTime? get lastUsed {
if (_lastUsed != null) return _lastUsed;
final lastUse = uses.fold<DateTime?>(null, (DateTime? last, use) {
if (last == null) return use.timeStamp;
return use.timeStamp.isAfter(last) ? use.timeStamp : last;
});
return _lastUsed = lastUse;
}
void setLastUsed(DateTime time) {
_lastUsed = time;
}
String get category => _category ?? "Other";
ConstructIdentifier get id => ConstructIdentifier(
lemma: lemma,
type: constructType,
category: category,
);
Map<String, dynamic> toJson() {
final json = {
'construct_id': id.toJson(),
'xp': points,
'last_used': lastUsed?.toIso8601String(),
/// NOTE - sent to server as just the useTypes
'uses': uses.map((e) => e.useType.string).toList(),
};
return json;
}
}

@ -145,7 +145,8 @@ class OneConstructUse {
for (final String category in morphCategoriesAndLabels.keys) {
if (morphCategoriesAndLabels[category]!.contains(morphLemma)) {
debugPrint(
"found missing construct category for $morphLemma: $category");
"found missing construct category for $morphLemma: $category",
);
return category;
}
}

@ -4,8 +4,6 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
import '../constants/model_keys.dart';
@ -13,6 +11,9 @@ import 'lemma.dart';
class PangeaToken {
PangeaTokenText text;
//TODO - make this a string and move save_vocab to this class
// clients have been able to handle null lemmas for 12 months so this is safe
Lemma lemma;
/// [pos] ex "VERB" - part of speech of the token
@ -120,37 +121,21 @@ class PangeaToken {
/// alias for the end of the token ie offset + length
int get end => text.offset + text.length;
/// create an empty tokenWithXP object
TokenWithXP get emptyTokenWithXP {
final List<ConstructWithXP> constructs = [];
constructs.add(
ConstructWithXP(
id: ConstructIdentifier(
lemma: lemma.text,
type: ConstructTypeEnum.vocab,
category: pos,
),
),
);
for (final morph in morph.entries) {
constructs.add(
ConstructWithXP(
id: ConstructIdentifier(
lemma: morph.value,
type: ConstructTypeEnum.morph,
category: morph.key,
),
),
);
}
return TokenWithXP(
token: this,
constructs: constructs,
);
}
bool get isContentWord => ["NOUN", "VERB", "ADJ", "ADV"].contains(pos);
bool get canBeHeard => [
"ADJ",
"ADV",
"AUX",
"DET",
"INTJ",
"NOUN",
"NUM",
"PRON",
"PROPN",
"SCONJ",
"VERB",
].contains(pos);
/// Given a [type] and [metadata], returns a [OneConstructUse] for this lemma
OneConstructUse toVocabUse(
@ -160,7 +145,7 @@ class PangeaToken {
return OneConstructUse(
useType: type,
lemma: lemma.text,
form: lemma.form,
form: text.content,
constructType: ConstructTypeEnum.vocab,
metadata: metadata,
category: pos,

@ -1,83 +1,59 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
class ConstructWithXP {
final ConstructIdentifier id;
int xp;
DateTime? lastUsed;
List<ConstructUseTypeEnum> condensedConstructUses;
ConstructWithXP({
required this.id,
this.xp = 0,
this.lastUsed,
this.condensedConstructUses = const [],
});
factory ConstructWithXP.fromJson(Map<String, dynamic> json) {
return ConstructWithXP(
id: ConstructIdentifier.fromJson(
json['construct_id'] as Map<String, dynamic>,
),
xp: json['xp'] as int,
lastUsed: json['last_used'] != null
? DateTime.parse(json['last_used'] as String)
: null,
condensedConstructUses: (json['uses'] as List<String>).map((e) {
return ConstructUseTypeUtil.fromString(e);
}).toList(),
);
}
Map<String, dynamic> toJson() {
final json = {
'construct_id': id.toJson(),
'xp': xp,
'last_used': lastUsed?.toIso8601String(),
'uses': condensedConstructUses.map((e) => e.string).toList(),
};
return json;
}
}
import 'package:fluffychat/widgets/matrix.dart';
class TokenWithXP {
final PangeaToken token;
final List<ConstructWithXP> constructs;
DateTime? get lastUsed {
return 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;
},
);
late List<ActivityTypeEnum> targetTypes;
TokenWithXP({
required this.token,
}) {
targetTypes = [];
}
int get xp {
return constructs.fold<int>(
0,
(previousValue, element) => previousValue + element.xp,
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;
}
TokenWithXP({
required this.token,
required this.constructs,
});
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>),
constructs: (json['constructs'] as List)
.map((e) => ConstructWithXP.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
@ -85,21 +61,96 @@ class TokenWithXP {
return {
'token': token.toJson(),
'constructs_with_xp': constructs.map((e) => e.toJson()).toList(),
'target_types': targetTypes.map((e) => e.string).toList(),
};
}
bool eligibleForActivity(ActivityTypeEnum a) {
switch (a) {
case ActivityTypeEnum.wordMeaning:
return token.isContentWord;
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
return token.canBeHeard;
}
}
bool didActivity(ActivityTypeEnum a) {
switch (a) {
case ActivityTypeEnum.wordMeaning:
return vocabConstruct.uses
.map((u) => u.useType)
.any((u) => a.associatedUseTypes.contains(u));
case ActivityTypeEnum.wordFocusListening:
return vocabConstruct.uses
// TODO - double-check that form is going to be available here
// .where((u) =>
// u.form?.toLowerCase() == token.text.content.toLowerCase(),)
.map((u) => u.useType)
.any((u) => a.associatedUseTypes.contains(u));
case ActivityTypeEnum.hiddenWordListening:
return vocabConstruct.uses
// TODO - double-check that form is going to be available here
// .where((u) =>
// u.form?.toLowerCase() == token.text.content.toLowerCase(),)
.map((u) => u.useType)
.any((u) => a.associatedUseTypes.contains(u));
}
}
ConstructUses get vocabConstruct {
final vocab = constructs.firstWhereOrNull(
(element) => element.id.type == ConstructTypeEnum.vocab,
);
if (vocab == null) {
return ConstructUses(
lemma: token.lemma.text,
constructType: ConstructTypeEnum.vocab,
category: token.pos,
uses: [],
);
}
return vocab;
}
int get xp {
return constructs.fold<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 &&
other.token.text == token.text &&
other.lastUsed == lastUsed;
const ListEquality().equals(other.constructs, constructs);
}
@override
int get hashCode {
return token.text.hashCode ^ lastUsed.hashCode;
return const ListEquality().hash(constructs);
}
}
@ -197,6 +248,10 @@ class MessageActivityRequest {
final List<ActivityTypeEnum> clientCompatibleActivities;
final ActivityTypeEnum clientTypeRequest;
final TokenWithXP clientTokenRequest;
MessageActivityRequest({
required this.userL1,
required this.userL2,
@ -205,52 +260,10 @@ class MessageActivityRequest {
required this.messageId,
required this.existingActivities,
required this.activityQualityFeedback,
clientCompatibleActivities,
}) : clientCompatibleActivities =
clientCompatibleActivities ?? ActivityTypeEnum.values;
factory MessageActivityRequest.fromJson(Map<String, dynamic> json) {
final clientCompatibleActivitiesEntry =
json['client_version_compatible_activity_types'];
List<ActivityTypeEnum>? clientCompatibleActivities;
if (clientCompatibleActivitiesEntry != null &&
clientCompatibleActivitiesEntry is List) {
clientCompatibleActivities = clientCompatibleActivitiesEntry
.map(
(e) => ActivityTypeEnum.values.firstWhereOrNull(
(element) =>
element.string == e as String ||
element.string.split('.').last == e,
),
)
.where((entry) => entry != null)
.cast<ActivityTypeEnum>()
.toList();
}
return MessageActivityRequest(
userL1: json['user_l1'] as String,
userL2: json['user_l2'] as String,
messageText: json['message_text'] as String,
tokensWithXP: (json['tokens_with_xp'] as List)
.map((e) => TokenWithXP.fromJson(e as Map<String, dynamic>))
.toList(),
messageId: json['message_id'] as String,
existingActivities: (json['existing_activities'] as List)
.map(
(e) => ExistingActivityMetaData.fromJson(e as Map<String, dynamic>),
)
.toList(),
activityQualityFeedback: json['activity_quality_feedback'] != null
? ActivityQualityFeedback.fromJson(
json['activity_quality_feedback'] as Map<String, dynamic>,
)
: null,
clientCompatibleActivities: clientCompatibleActivities != null &&
clientCompatibleActivities.isNotEmpty
? clientCompatibleActivities
: ActivityTypeEnum.values,
);
}
required this.clientCompatibleActivities,
required this.clientTokenRequest,
required this.clientTypeRequest,
});
Map<String, dynamic> toJson() {
return {
@ -267,6 +280,8 @@ class MessageActivityRequest {
// this for backwards compatibility with old clients
'client_version_compatible_activity_types':
clientCompatibleActivities.map((e) => e.string).toList(),
'client_type_request': clientTypeRequest.string,
'client_token_request': clientTokenRequest.toJson(),
};
}

@ -224,13 +224,8 @@ class PracticeActivityModel {
.toList(),
langCode: json['lang_code'] as String,
msgId: json['msg_id'] as String,
activityType: json['activity_type'] == "multipleChoice"
? ActivityTypeEnum.multipleChoice
: ActivityTypeEnum.values.firstWhere(
(e) =>
e.string == json['activity_type'] as String ||
e.string.split('.').last == json['activity_type'] as String,
),
activityType:
ActivityTypeEnum.wordMeaning.fromString(json['activity_type']),
content: ActivityContent.fromJson(contentMap),
);
}

@ -4,6 +4,7 @@
// finding the answer
import 'dart:developer';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
@ -131,8 +132,22 @@ class ActivityRecordResponse {
});
//TODO - differentiate into different activity types
ConstructUseTypeEnum get useType =>
score > 0 ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA;
ConstructUseTypeEnum useType(ActivityTypeEnum aType) {
switch (aType) {
case ActivityTypeEnum.wordMeaning:
return score > 0
? ConstructUseTypeEnum.corPA
: ConstructUseTypeEnum.incPA;
case ActivityTypeEnum.wordFocusListening:
return score > 0
? ConstructUseTypeEnum.corWL
: ConstructUseTypeEnum.incWL;
case ActivityTypeEnum.hiddenWordListening:
return score > 0
? ConstructUseTypeEnum.corHWL
: ConstructUseTypeEnum.incHWL;
}
}
// for each target construct create a OneConstructUse object
List<OneConstructUse> toUses(
@ -146,7 +161,7 @@ class ActivityRecordResponse {
// TODO - add form to practiceActivity target_construct data somehow
form: null,
constructType: construct.type,
useType: useType,
useType: useType(practiceActivity.activityType),
metadata: metadata,
category: construct.category,
),

@ -12,13 +12,13 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_token_text.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_header.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/target_tokens_controller.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
@ -75,8 +75,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
bool isPlayingAudio = false;
bool get showToolbarButtons => !widget._pangeaMessageEvent.isAudioMessage;
final TargetTokensController targetTokensController =
TargetTokensController();
List<PangeaToken>? tokens;
@override
void initState() {
@ -87,6 +87,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
const Duration(milliseconds: AppConfig.overlayAnimationDuration),
);
_getTokens();
activitiesLeftToComplete = activitiesLeftToComplete -
widget._pangeaMessageEvent.numberOfActivitiesCompleted;
@ -114,6 +116,39 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
setInitialToolbarMode();
}
MessageTokenText get messageTokenText => MessageTokenText(
ownMessage: pangeaMessageEvent.ownMessage,
fullText: pangeaMessageEvent.messageDisplayText,
tokensWithDisplay: tokens
?.map(
(token) => TokenWithDisplayInstructions(
token: token,
highlight: isTokenSelected(token),
//NOTE: we actually do want the controller to be aware of which
// tokens are currently being involved in activities and adjust here
hideContent: false,
),
)
.toList(),
onClick: onClickOverlayMessageToken,
);
Future<void> _getTokens() async {
tokens = pangeaMessageEvent.originalSent?.tokens;
if (pangeaMessageEvent.originalSent != null && tokens == null) {
pangeaMessageEvent.originalSent!
.tokensGlobal(
pangeaMessageEvent.senderId,
pangeaMessageEvent.originServerTs,
)
.then((tokens) {
// this isn't currently working because originalSent's _event is null
setState(() => this.tokens = tokens);
});
}
}
/// We need to check if the setState call is safe to call immediately
/// Kept getting the error: setState() or markNeedsBuild() called during build.
/// This is a workaround to prevent that error
@ -493,7 +528,6 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
pangeaMessageEvent: widget._pangeaMessageEvent,
overLayController: this,
ttsController: tts,
targetTokensController: targetTokensController,
),
const SizedBox(height: 8),
SizedBox(

@ -0,0 +1,138 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
class MessageTokenText extends StatelessWidget {
final PangeaController pangeaController = MatrixState.pangeaController;
final bool ownMessage;
/// this must match the tokens or we've got problems
final String fullText;
/// this must match the fullText or we've got problems
final List<TokenWithDisplayInstructions>? tokensWithDisplay;
final void Function(PangeaToken)? onClick;
MessageTokenText({
super.key,
required this.ownMessage,
required this.fullText,
required this.tokensWithDisplay,
required this.onClick,
});
@override
Widget build(BuildContext context) {
final style = TextStyle(
color: ownMessage
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurface,
height: 1.3,
fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor,
);
if (tokensWithDisplay == null || tokensWithDisplay!.isEmpty) {
return Text(
fullText,
style: style,
);
}
// Convert the entire message into a list of characters
final Characters messageCharacters = fullText.characters;
// When building token positions, use grapheme cluster indices
final List<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;
// Calculate the number of grapheme clusters up to the start and end positions
final int startIndex = messageCharacters.take(start).length;
final int endIndex = messageCharacters.take(end).length;
if (globalIndex < startIndex) {
tokenPositions.add(TokenPosition(start: globalIndex, end: startIndex));
}
tokenPositions.add(
TokenPosition(
start: startIndex,
end: endIndex,
tokenIndex: i,
token: tokenWithDisplay,
),
);
globalIndex = endIndex;
}
//TODO - take out of build function of every message
return RichText(
text: TextSpan(
children: tokenPositions.map((tokenPosition) {
final substring = messageCharacters
.skip(tokenPosition.start)
.take(tokenPosition.end - tokenPosition.start)
.toString();
if (tokenPosition.token != null) {
return TextSpan(
recognizer: TapGestureRecognizer()
..onTap = () => onClick != null
? onClick!(tokenPosition.token!.token)
: null,
text: !tokenPosition.token!.hideContent ? substring : '_____',
style: style.merge(
TextStyle(
backgroundColor: tokenPosition.token!.highlight
? Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(0.4)
: Colors.white.withOpacity(0.4)
: Colors.transparent,
),
),
);
} else {
return TextSpan(
text: substring,
style: style,
);
}
}).toList(),
),
);
}
}
class TokenWithDisplayInstructions {
final PangeaToken token;
final bool highlight;
final bool hideContent;
TokenWithDisplayInstructions({
required this.token,
required this.highlight,
required this.hideContent,
});
}
class TokenPosition {
final int start;
final int end;
final TokenWithDisplayInstructions? token;
final int tokenIndex;
const TokenPosition({
required this.start,
required this.end,
this.token,
this.tokenIndex = -1,
});
}

@ -0,0 +1,125 @@
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Question - does this need to be stateful or does this work?
/// Need to test.
class MessageTokenTextStateful extends StatelessWidget {
final PangeaController pangeaController = MatrixState.pangeaController;
final MessageAnalyticsEntry messageAnalyticsEntry;
final TextStyle style;
final void Function(PangeaToken)? onClick;
bool get ownMessage => messageAnalyticsEntry.pmEvent.ownMessage;
MessageTokenTextStateful({
super.key,
required this.messageAnalyticsEntry,
required this.style,
required this.onClick,
});
PangeaMessageEvent get pangeaMessageEvent => messageAnalyticsEntry.pmEvent;
@override
Widget build(BuildContext context) {
// Convert the entire message into a list of characters
final Characters messageCharacters =
pangeaMessageEvent.messageDisplayText.characters;
// When building token positions, use grapheme cluster indices
final List<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;
if (globalIndex < startIndex) {
tokenPositions.add(
TokenPosition(
start: globalIndex,
end: startIndex,
hideContent: false,
highlight: false,
),
);
}
tokenPositions.add(
TokenPosition(
start: startIndex,
end: endIndex,
token: token.token,
hideContent:
token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening),
highlight: false,
),
);
globalIndex = endIndex;
}
return RichText(
text: TextSpan(
children: tokenPositions.map((tokenPosition) {
final substring = messageCharacters
.skip(tokenPosition.start)
.take(tokenPosition.end - tokenPosition.start)
.toString();
if (tokenPosition.token != null) {
return TextSpan(
recognizer: TapGestureRecognizer()
..onTap = () => onClick != null && tokenPosition.token != null
? onClick!(tokenPosition.token!)
: null,
text: !tokenPosition.hideContent ? substring : '_____',
style: style.merge(
TextStyle(
backgroundColor: tokenPosition.highlight
? Theme.of(context).brightness == Brightness.light
? Colors.black.withOpacity(0.4)
: Colors.white.withOpacity(0.4)
: Colors.transparent,
),
),
);
} else {
return TextSpan(
text: substring,
style: style,
);
}
}).toList(),
),
);
}
}
class TokenPosition {
final int start;
final int end;
final bool highlight;
final bool hideContent;
final PangeaToken? token;
const TokenPosition({
required this.start,
required this.end,
required this.hideContent,
required this.highlight,
this.token,
});
}

@ -16,7 +16,6 @@ import 'package:fluffychat/pangea/widgets/igc/card_error_widget.dart';
import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart';
import 'package:fluffychat/pangea/widgets/message_display_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/target_tokens_controller.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -28,14 +27,12 @@ class MessageToolbar extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overLayController;
final TtsController ttsController;
final TargetTokensController targetTokensController;
const MessageToolbar({
super.key,
required this.pangeaMessageEvent,
required this.overLayController,
required this.ttsController,
required this.targetTokensController,
});
Widget toolbarContent(BuildContext context) {
@ -73,7 +70,9 @@ class MessageToolbar extends StatelessWidget {
case MessageMode.definition:
if (!overLayController.isSelection) {
return FutureBuilder(
future: targetTokensController.targetTokens(pangeaMessageEvent),
//TODO - convert this to synchronous if possible
future: Future.value(
pangeaMessageEvent.messageDisplayRepresentation?.tokens),
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const ToolbarContentLoadingIndicator();
@ -131,7 +130,6 @@ class MessageToolbar extends StatelessWidget {
pangeaMessageEvent: pangeaMessageEvent,
overlayController: overLayController,
ttsController: ttsController,
targetTokensController: targetTokensController,
);
default:
debugger(when: kDebugMode);

@ -8,6 +8,7 @@ import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
// @ggurdin be great to explain the need/function of a widget like this
class OverlayMessage extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController;

@ -2,6 +2,7 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart';
import 'package:fluffychat/pangea/widgets/chat_list/analytics_summary/analytics_popup/analytics_xp_tile.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';

@ -1,7 +1,7 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart';
import 'package:fluffychat/pangea/utils/grammar/get_grammar_copy.dart';
import 'package:flutter/material.dart';

@ -1,62 +0,0 @@
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class GeneratePracticeActivityButton extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final Function(PracticeActivityEvent?) onActivityGenerated;
const GeneratePracticeActivityButton({
super.key,
required this.pangeaMessageEvent,
required this.onActivityGenerated,
});
//TODO - probably disable the generation of activities for specific messages
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final String? l2Code = MatrixState.pangeaController.languageController
.activeL1Model()
?.langCode;
if (l2Code == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.noLanguagesSet),
),
);
return;
}
throw UnimplementedError();
// final PracticeActivityEvent? practiceActivityEvent = await MatrixState
// .pangeaController.practiceGenerationController
// .getPracticeActivity(
// MessageActivityRequest(
// candidateMessages: [
// CandidateMessage(
// msgId: pangeaMessageEvent.eventId,
// roomId: pangeaMessageEvent.room.id,
// text:
// pangeaMessageEvent.representationByLanguage(l2Code)?.text ??
// pangeaMessageEvent.body,
// ),
// ],
// userIds: pangeaMessageEvent.room.client.userID != null
// ? [pangeaMessageEvent.room.client.userID!]
// : null,
// ),
// pangeaMessageEvent,
// );
// onActivityGenerated(practiceActivityEvent);
},
child: Text(L10n.of(context)!.practice),
);
}
}

@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activ
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart';
import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/word_audio_button.dart';
@ -99,7 +100,10 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
// If the selected choice is correct, send the record and get the next activity
if (widget.currentActivity.content.isCorrect(value, index)) {
widget.practiceCardController.onActivityFinish();
MatrixState.pangeaController.getAnalytics.analyticsStream.stream.first
.then((_) {
widget.practiceCardController.onActivityFinish();
});
}
if (mounted) {
@ -129,6 +133,17 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
ttsController: widget.tts,
eventID: widget.eventID,
),
if (practiceActivity.activityType ==
ActivityTypeEnum.hiddenWordListening)
MessageAudioCard(
messageEvent:
widget.practiceCardController.widget.pangeaMessageEvent,
overlayController:
widget.practiceCardController.widget.overlayController,
tts: widget.practiceCardController.widget.overlayController.tts,
setIsPlayingAudio: widget.practiceCardController.widget
.overlayController.setIsPlayingAudio,
),
ChoicesArray(
isLoading: false,
uniqueKeyForLayerLink: (index) => "multiple_choice_$index",

@ -19,7 +19,6 @@ import 'package:fluffychat/pangea/widgets/chat/tts_controller.dart';
import 'package:fluffychat/pangea/widgets/content_issue_button.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/no_more_practice_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/target_tokens_controller.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -32,14 +31,12 @@ class PracticeActivityCard extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController;
final TtsController ttsController;
final TargetTokensController targetTokensController;
const PracticeActivityCard({
super.key,
required this.pangeaMessageEvent,
required this.overlayController,
required this.ttsController,
required this.targetTokensController,
});
@override
@ -56,6 +53,30 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
List<PracticeActivityEvent> get practiceActivities =>
widget.pangeaMessageEvent.practiceActivities;
PracticeActivityEvent? get existingActivityMatchingNeeds {
final messageAnalyticsEntry = pangeaController.getAnalytics.perMessage
.get(widget.pangeaMessageEvent, false);
if (messageAnalyticsEntry?.nextActivityToken == null) {
debugger(when: kDebugMode);
return null;
}
for (final existingActivity in practiceActivities) {
for (final c in messageAnalyticsEntry!.nextActivityToken!.constructs) {
if (existingActivity.practiceActivity.tgtConstructs
.any((tc) => tc == c.id) &&
existingActivity.practiceActivity.activityType ==
messageAnalyticsEntry.nextActivityType) {
debugPrint('found existing activity');
return existingActivity;
}
}
}
return null;
}
// Used to show an animation when the user completes an activity
// while simultaneously fetching a new activity and not showing the loading spinner
// until the appropriate time has passed to 'savor the joy'
@ -96,86 +117,118 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
/// If not, get a new activity from the server.
Future<void> initialize() async {
_setPracticeActivity(
await _fetchNewActivity(),
await _fetchActivity(),
);
}
Future<PracticeActivityModel?> _fetchNewActivity([
Future<PracticeActivityModel?> _fetchActivity([
ActivityQualityFeedback? activityFeedback,
]) async {
try {
debugPrint('Fetching new activity');
_updateFetchingActivity(true);
// target tokens can be empty if activities have been completed for each
// it's set on initialization and then removed when each activity is completed
if (!pangeaController.languageController.languagesSet) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
if (!mounted) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
if (widget.pangeaMessageEvent.messageDisplayRepresentation == null) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
ErrorHandler.logError(
e: Exception('No original message found in _fetchNewActivity'),
data: {
'event': widget.pangeaMessageEvent.event.toJson(),
},
);
return null;
}
final PracticeActivityModelResponse? activityResponse =
await pangeaController.practiceGenerationController
.getPracticeActivity(
MessageActivityRequest(
userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode,
messageText: widget.pangeaMessageEvent.messageDisplayText,
tokensWithXP: await widget.targetTokensController.targetTokens(
widget.pangeaMessageEvent,
),
messageId: widget.pangeaMessageEvent.eventId,
existingActivities: practiceActivities
.map((activity) => activity.activityRequestMetaData)
.toList(),
activityQualityFeedback: activityFeedback,
clientCompatibleActivities: widget
.ttsController.isLanguageFullySupported
? ActivityTypeEnum.values
: ActivityTypeEnum.values
.where((type) => type != ActivityTypeEnum.wordFocusListening)
.toList(),
),
widget.pangeaMessageEvent,
);
// temporary
// try {
debugPrint('Fetching activity');
// debugger();
_updateFetchingActivity(true);
// target tokens can be empty if activities have been completed for each
// it's set on initialization and then removed when each activity is completed
if (!pangeaController.languageController.languagesSet) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
currentActivityCompleter = activityResponse?.eventCompleter;
if (!mounted) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
return activityResponse?.activity;
} catch (e, s) {
if (widget.pangeaMessageEvent.messageDisplayRepresentation == null) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
ErrorHandler.logError(
e: e,
s: s,
m: 'Failed to get new activity',
e: Exception('No original message found in _fetchNewActivity'),
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
'event': widget.pangeaMessageEvent.event.toJson(),
},
);
return null;
}
if (widget.pangeaMessageEvent.messageDisplayRepresentation?.tokens ==
null) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
final messageAnalyticsEntry = pangeaController.getAnalytics.perMessage
.get(widget.pangeaMessageEvent, false);
// the client is going to be choosing the next activity now
// if nothing is set then it must be done with practice
if (messageAnalyticsEntry?.nextActivityToken == null ||
messageAnalyticsEntry?.nextActivityType == null) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
final existingActivity = existingActivityMatchingNeeds;
if (existingActivity != null) {
return existingActivity.practiceActivity;
}
debugPrint(
"client requesting activity type: ${messageAnalyticsEntry?.nextActivityType}",
);
debugPrint(
"client requesting token: ${messageAnalyticsEntry?.nextActivityToken?.token.text.content}",
);
final PracticeActivityModelResponse? activityResponse =
await pangeaController.practiceGenerationController.getPracticeActivity(
MessageActivityRequest(
userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode,
messageText: widget.pangeaMessageEvent.originalSent!.text,
tokensWithXP: messageAnalyticsEntry!.tokensWithXp,
messageId: widget.pangeaMessageEvent.eventId,
existingActivities: practiceActivities
.map((activity) => activity.activityRequestMetaData)
.toList(),
activityQualityFeedback: activityFeedback,
clientCompatibleActivities: widget
.ttsController.isLanguageFullySupported
? ActivityTypeEnum.values
: ActivityTypeEnum.values
.where((type) => type != ActivityTypeEnum.wordFocusListening)
.toList(),
clientTokenRequest: messageAnalyticsEntry.nextActivityToken!,
clientTypeRequest: messageAnalyticsEntry.nextActivityType!,
),
widget.pangeaMessageEvent,
);
currentActivityCompleter = activityResponse?.eventCompleter;
_updateFetchingActivity(false);
return activityResponse?.activity;
// } catch (e, s) {
// debugger(when: kDebugMode);
// ErrorHandler.logError(
// e: e,
// s: s,
// m: 'Failed to get new activity',
// data: {
// 'activity': currentActivity,
// 'record': currentCompletionRecord,
// },
// );
// return null;
// }
}
ConstructUseMetaData get metadata => ConstructUseMetaData(
@ -220,13 +273,17 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
// update the target tokens with the new construct uses
// NOTE - multiple choice activity is handling adding these to analytics
await widget.targetTokensController.updateTokensWithConstructs(
currentCompletionRecord!.usesForAllResponses(
currentActivity!,
metadata,
),
widget.pangeaMessageEvent,
);
// previously we would update the tokens with the constructs
// now the tokens themselves calculate their own points using the analytics
// we're going to see if this creates performance issues
final messageAnalytics = MatrixState
.pangeaController.getAnalytics.perMessage
.get(widget.pangeaMessageEvent, false);
// messageAnalytics will only be null if there are no tokens to update
// set the target types for the next activity
messageAnalytics!.computeTargetTypesForMessageAsync();
widget.overlayController.onActivityFinish();
pangeaController.activityRecordController.completeActivity(
@ -237,7 +294,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
// and setting it to replace the previous activity
final Iterable<dynamic> result = await Future.wait([
_savorTheJoy(),
_fetchNewActivity(),
_fetchActivity(),
]);
_setPracticeActivity(result.last as PracticeActivityModel?);
@ -279,7 +336,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
);
}
_fetchNewActivity(
_fetchActivity(
ActivityQualityFeedback(
feedbackText: feedback,
badActivity: currentActivity!,
@ -315,34 +372,15 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
switch (currentActivity?.activityType) {
case null:
return null;
case ActivityTypeEnum.multipleChoice:
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity!,
tts: widget.ttsController,
eventID: widget.pangeaMessageEvent.eventId,
);
case ActivityTypeEnum.wordFocusListening:
// return WordFocusListeningActivity(
// activity: currentActivity!, practiceCardController: this);
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.wordMeaning:
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity!,
tts: widget.ttsController,
eventID: widget.pangeaMessageEvent.eventId,
);
// default:
// ErrorHandler.logError(
// e: Exception('Unknown activity type'),
// m: 'Unknown activity type',
// data: {
// 'activityType': currentActivity!.activityType,
// },
// );
// return Text(
// L10n.of(context)!.oopsSomethingWentWrong,
// style: BotStyle.text(context),
// );
}
}

@ -1,88 +0,0 @@
import 'dart:developer';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
/// Seperated out the target tokens from the practice activity card
/// in order to control the state of the target tokens
class TargetTokensController {
List<TokenWithXP>? _targetTokens;
TargetTokensController();
/// From the tokens in the message, do a preliminary filtering of which to target
/// Then get the construct uses for those tokens
Future<List<TokenWithXP>> targetTokens(
PangeaMessageEvent pangeaMessageEvent,
) async {
if (_targetTokens != null) {
return _targetTokens!;
}
_targetTokens = await _initialize(pangeaMessageEvent);
// final allConstructs = MatrixState
// .pangeaController.getAnalytics.analyticsStream.value?.constructs;
// await updateTokensWithConstructs(
// allConstructs ?? [],
// pangeaMessageEvent,
// );
return _targetTokens!;
}
Future<List<TokenWithXP>> _initialize(
PangeaMessageEvent pangeaMessageEvent,
) async {
final tokens =
await pangeaMessageEvent.messageDisplayRepresentation?.tokensGlobal(
pangeaMessageEvent.senderId,
pangeaMessageEvent.originServerTs,
);
if (tokens == null || tokens.isEmpty) {
debugger(when: kDebugMode);
return _targetTokens = [];
}
return _targetTokens =
tokens.map((token) => token.emptyTokenWithXP).toList();
}
Future<void> updateTokensWithConstructs(
List<OneConstructUse> constructUses,
pangeaMessageEvent,
) async {
final ConstructListModel constructList = ConstructListModel(
uses: constructUses,
// type: null,
);
_targetTokens ??= await _initialize(pangeaMessageEvent);
for (final token in _targetTokens!) {
// we don't need to do this for tokens that don't have saveVocab set to true
if (!token.token.lemma.saveVocab) {
continue;
}
for (final construct in token.constructs) {
final constructUseModel = constructList.getConstructUses(
ConstructIdentifier(
lemma: construct.id.lemma,
type: construct.id.type,
category: construct.id.category,
),
);
if (constructUseModel != null) {
construct.xp += constructUseModel.points;
construct.lastUsed = constructUseModel.lastUsed;
}
}
}
}
}
Loading…
Cancel
Save