You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fluffychat/lib/pangea/practice_activities/practice_selection.dart

323 lines
10 KiB
Dart

import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/widgets/matrix.dart';
class PracticeSelection {
late String _userL2;
final DateTime createdAt = DateTime.now();
late final List<PangeaToken> _tokens;
final String langCode;
final Map<ActivityTypeEnum, List<PracticeTarget>> _activityQueue = {};
final int _maxQueueLength = 5;
PracticeSelection({
required List<PangeaToken> tokens,
required this.langCode,
String? userL1,
String? userL2,
}) {
_userL2 = userL2 ??
MatrixState.pangeaController.languageController.userL2?.langCode ??
LanguageKeys.defaultLanguage;
_tokens = tokens;
initialize();
}
List<PangeaToken> get tokens => _tokens;
bool get eligibleForPractice =>
_tokens.any((t) => t.lemma.saveVocab) &&
langCode.split("-")[0] == _userL2.split("-")[0];
Map<String, dynamic> toJson() => {
'createdAt': createdAt.toIso8601String(),
'lang_code': langCode,
'tokens': _tokens.map((t) => t.toJson()).toList(),
'activityQueue': _activityQueue.map(
(key, value) => MapEntry(
key.toString(),
value.map((e) => e.toJson()).toList(),
),
),
};
static PracticeSelection fromJson(Map<String, dynamic> json) {
return PracticeSelection(
langCode: json['lang_code'] as String,
tokens:
(json['tokens'] as List).map((t) => PangeaToken.fromJson(t)).toList(),
).._activityQueue.addAll(
(json['activityQueue'] as Map<String, dynamic>).map(
(key, value) => MapEntry(
ActivityTypeEnum.values.firstWhere((e) => e.toString() == key),
(value as List).map((e) => PracticeTarget.fromJson(e)).toList(),
),
),
);
}
void _pushQueue(PracticeTarget entry) {
if (_activityQueue.containsKey(entry.activityType)) {
_activityQueue[entry.activityType]!.insert(0, entry);
} else {
_activityQueue[entry.activityType] = [entry];
}
// just in case we make a mistake and the queue gets too long
if (_activityQueue[entry.activityType]!.length > _maxQueueLength) {
debugger(when: kDebugMode);
_activityQueue[entry.activityType]!.removeRange(
_maxQueueLength,
_activityQueue.length,
);
}
}
PracticeTarget? nextActivity(ActivityTypeEnum a) =>
MatrixState.pangeaController.languageController.userL2?.langCode ==
_userL2
? _activityQueue[a]?.firstOrNull
: null;
bool get hasHiddenWordActivity =>
activities(ActivityTypeEnum.hiddenWordListening).isNotEmpty;
bool get hasMessageMeaningActivity =>
activities(ActivityTypeEnum.messageMeaning).isNotEmpty;
int get numActivities => _activityQueue.length;
List<PracticeTarget> activities(ActivityTypeEnum a) =>
_activityQueue[a] ?? [];
// /// If there are more than 4 tokens that can be heard, we don't want to do word focus listening
// /// Otherwise, we don't have enough distractors
// bool get canDoWordFocusListening =>
// _tokens.where((t) => t.canBeHeard).length > 4;
bool tokenIsIncludedInActivityOfAnyType(
PangeaToken t,
) {
return _activityQueue.entries.any(
(perActivityQueue) => perActivityQueue.value.any(
(entry) => entry.tokens.contains(t),
),
);
}
List<PracticeTarget> buildActivity(ActivityTypeEnum activityType) {
if (!eligibleForPractice) {
return [];
}
final List<PangeaToken> basicallyEligible =
_tokens.where((t) => t.lemma.saveVocab).toList();
// list of tokens with unique lemmas and surface forms
final List<PangeaToken> tokens = [];
for (final t in basicallyEligible) {
if (!tokens.any(
(token) =>
token.lemma == t.lemma && token.text.content == t.text.content,
)) {
tokens.add(t);
}
}
tokens.sort(
(a, b) {
final bScore = b.activityPriorityScore(activityType, null) *
(tokenIsIncludedInActivityOfAnyType(b) ? 1.1 : 1);
final aScore = a.activityPriorityScore(activityType, null) *
(tokenIsIncludedInActivityOfAnyType(a) ? 1.1 : 1);
return bScore.compareTo(aScore);
},
);
if (tokens.isEmpty) {
return [];
}
if (tokens.length < activityType.minTokensForMatchActivity) {
// if we only have one token, we don't need to do an emoji activity
return [];
}
return [
PracticeTarget(
activityType: activityType,
tokens: tokens.take(_maxQueueLength).shuffled().toList(),
userL2: _userL2,
),
];
}
List<PracticeTarget> buildMorphActivity() {
final eligibleTokens = _tokens.where((t) => t.lemma.saveVocab);
if (!eligibleForPractice) {
return [];
}
final List<PracticeTarget> candidates = eligibleTokens.expand(
(t) {
return t.morphsBasicallyEligibleForPracticeByPriority.map(
(m) => PracticeTarget(
tokens: [t],
activityType: ActivityTypeEnum.morphId,
morphFeature: MorphFeaturesEnumExtension.fromString(m.category),
userL2: _userL2,
),
);
},
).sorted(
(a, b) {
final bScore = b.tokens.first.activityPriorityScore(
ActivityTypeEnum.morphId,
b.morphFeature!,
) *
(tokenIsIncludedInActivityOfAnyType(b.tokens.first) ? 1.1 : 1);
final aScore = a.tokens.first.activityPriorityScore(
ActivityTypeEnum.morphId,
a.morphFeature!,
) *
(tokenIsIncludedInActivityOfAnyType(a.tokens.first) ? 1.1 : 1);
return bScore.compareTo(aScore);
},
);
//pick from the top 5, only including one per token
final List<PracticeTarget> finalSelection = [];
for (final candidate in candidates) {
if (finalSelection.length >= _maxQueueLength) {
break;
}
if (finalSelection.any(
(entry) => entry.tokens.contains(candidate.tokens.first),
) ==
false) {
finalSelection.add(candidate);
}
}
return finalSelection;
}
/// On initialization, we pick which tokens to do activities on and what types of activities to do
void initialize() {
// EMOJI
// sort the tokens by the preference of them for an emoji activity
// order from least to most recent
// words that have never been used are counted as 1000 days
// we preference content words over function words by multiplying the days since last use by 2
// NOTE: for now, we put it at the end if it has no uses and basically just give them the answer
// later on, we may introduce an emoji activity that is easier than the current matching one
// i.e. we show them 3 good emojis and 1 bad one and ask them to pick the bad one
_activityQueue[ActivityTypeEnum.emoji] =
buildActivity(ActivityTypeEnum.emoji);
// WORD MEANING
// make word meaning activities
// same as emojis for now
_activityQueue[ActivityTypeEnum.wordMeaning] =
buildActivity(ActivityTypeEnum.wordMeaning);
// WORD FOCUS LISTENING
// make word focus listening activities
// same as emojis for now
_activityQueue[ActivityTypeEnum.wordFocusListening] =
buildActivity(ActivityTypeEnum.wordFocusListening);
// GRAMMAR
// build a list of TargetTokensAndActivityType for all tokens and all features in the message
// limits to _maxQueueLength activities and only one per token
_activityQueue[ActivityTypeEnum.morphId] = buildMorphActivity();
PracticeSelectionRepo.save(this);
}
PracticeTarget? getSelection(
ActivityTypeEnum a, [
PangeaToken? t,
MorphFeaturesEnum? morph,
]) {
if (a == ActivityTypeEnum.morphId && (t == null || morph == null)) {
return null;
}
return activities(a).firstWhereOrNull(
(entry) =>
(t == null || entry.tokens.contains(t)) &&
(morph == null || entry.morphFeature == morph),
);
}
bool hasActiveActivityByToken(
ActivityTypeEnum a,
PangeaToken t, [
MorphFeaturesEnum? morph,
]) =>
getSelection(a, t, morph)?.isCompleteByToken(t, morph) == false;
/// Add a message meaning activity to the front of the queue
/// And limits to _maxQueueLength activities
void addMessageMeaningActivity() {
final entry = PracticeTarget(
tokens: _tokens,
activityType: ActivityTypeEnum.messageMeaning,
userL2: _userL2,
);
_pushQueue(entry);
}
void exitPracticeFlow() {
_activityQueue.clear();
PracticeSelectionRepo.save(this);
}
void revealAllTokens() {
_activityQueue[ActivityTypeEnum.hiddenWordListening]?.clear();
PracticeSelectionRepo.save(this);
}
bool isTokenInHiddenWordActivity(PangeaToken token) =>
_activityQueue[ActivityTypeEnum.hiddenWordListening]?.isNotEmpty ?? false;
Future<List<LemmaInfoResponse>> getLemmaInfoForActivityTokens() async {
// make a list of unique tokens in emoji and wordMeaning activities
final List<PangeaToken> uniqueTokens = [];
for (final t in _activityQueue[ActivityTypeEnum.emoji] ?? []) {
if (!uniqueTokens.contains(t.tokens.first)) {
uniqueTokens.add(t.tokens.first);
}
}
for (final t in _activityQueue[ActivityTypeEnum.wordMeaning] ?? []) {
if (!uniqueTokens.contains(t.tokens.first)) {
uniqueTokens.add(t.tokens.first);
}
}
// get the lemma info for each token
final List<Future<LemmaInfoResponse>> lemmaInfoFutures = [];
for (final t in uniqueTokens) {
lemmaInfoFutures.add(t.vocabConstructID.getLemmaInfo());
}
return Future.wait(lemmaInfoFutures);
}
}