refactor: reorganize / simplify practice mode (#4755)
* refactor: reorganize / simplify practice mode * cleanup * remove unreferenced code * only use content words in emoji activities
This commit is contained in:
parent
cd4600501d
commit
660b92fdf1
51 changed files with 1303 additions and 3779 deletions
|
|
@ -14,7 +14,6 @@ import 'package:path_provider/path_provider.dart';
|
|||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pages/chat/chat.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/utils/error_reporter.dart';
|
||||
|
|
@ -35,7 +34,6 @@ class AudioPlayerWidget extends StatefulWidget {
|
|||
final String roomId;
|
||||
final String senderId;
|
||||
final PangeaAudioFile? matrixFile;
|
||||
final ChatController chatController;
|
||||
final MessageOverlayController? overlayController;
|
||||
final bool autoplay;
|
||||
// Pangea#
|
||||
|
|
@ -52,7 +50,6 @@ class AudioPlayerWidget extends StatefulWidget {
|
|||
required this.roomId,
|
||||
required this.senderId,
|
||||
this.matrixFile,
|
||||
required this.chatController,
|
||||
this.overlayController,
|
||||
this.autoplay = false,
|
||||
// Pangea#
|
||||
|
|
|
|||
|
|
@ -449,10 +449,12 @@ class HtmlMessage extends StatelessWidget {
|
|||
overlayController!.showTokenEmojiPopup(token),
|
||||
selectModeNotifier: overlayController!.selectedMode,
|
||||
),
|
||||
if (renderer.showCenterStyling && token != null)
|
||||
if (renderer.showCenterStyling &&
|
||||
token != null &&
|
||||
overlayController != null)
|
||||
TokenPracticeButton(
|
||||
token: token,
|
||||
overlayController: overlayController,
|
||||
controller: overlayController!.practiceController,
|
||||
textStyle: renderer.style(
|
||||
context,
|
||||
color: renderer.backgroundColor(
|
||||
|
|
@ -465,7 +467,6 @@ class HtmlMessage extends StatelessWidget {
|
|||
),
|
||||
),
|
||||
width: tokenWidth,
|
||||
animateIn: isTransitionAnimation,
|
||||
textColor: textColor,
|
||||
),
|
||||
CompositedTransformTarget(
|
||||
|
|
|
|||
|
|
@ -214,7 +214,6 @@ class MessageContent extends StatelessWidget {
|
|||
linkColor: linkColor,
|
||||
fontSize: fontSize,
|
||||
// #Pangea
|
||||
chatController: controller,
|
||||
eventId:
|
||||
"${event.eventId}${overlayController != null ? '_overlay' : ''}",
|
||||
roomId: event.room.id,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/choreographer/it/completed_it_step_model.dart'
|
|||
import 'package:fluffychat/pangea/choreographer/pangea_message_content_model.dart';
|
||||
import 'package:fluffychat/pangea/choreographer/text_editing/edit_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/choreographer/text_editing/pangea_text_controller.dart';
|
||||
import 'package:fluffychat/pangea/events/controllers/message_data_controller.dart';
|
||||
import 'package:fluffychat/pangea/events/models/representation_content_model.dart';
|
||||
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
|
||||
import 'package:fluffychat/pangea/events/repo/token_api_models.dart';
|
||||
|
|
@ -264,17 +265,15 @@ class Choreographer extends ChangeNotifier {
|
|||
final l1LangCode =
|
||||
MatrixState.pangeaController.languageController.userL1?.langCode;
|
||||
if (l1LangCode != null && l2LangCode != null) {
|
||||
final res = await MatrixState.pangeaController.messageData
|
||||
.getTokens(
|
||||
repEventId: null,
|
||||
room: null,
|
||||
req: TokensRequestModel(
|
||||
fullText: message,
|
||||
senderL1: l1LangCode,
|
||||
senderL2: l2LangCode,
|
||||
),
|
||||
)
|
||||
.timeout(const Duration(seconds: 10));
|
||||
final res = await MessageDataController.getTokens(
|
||||
repEventId: null,
|
||||
room: null,
|
||||
req: TokensRequestModel(
|
||||
fullText: message,
|
||||
senderL1: l1LangCode,
|
||||
senderL2: l2LangCode,
|
||||
),
|
||||
).timeout(const Duration(seconds: 10));
|
||||
tokensResp = res.isValue ? res.result : null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import 'package:fluffychat/pangea/bot/utils/bot_room_extension.dart';
|
|||
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
|
||||
import 'package:fluffychat/pangea/chat_settings/utils/bot_client_extension.dart';
|
||||
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
|
||||
import 'package:fluffychat/pangea/events/controllers/message_data_controller.dart';
|
||||
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
|
||||
import 'package:fluffychat/pangea/guard/p_vguard.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/controllers/language_controller.dart';
|
||||
|
|
@ -39,8 +38,6 @@ class PangeaController {
|
|||
late PermissionsController permissionsController;
|
||||
late GetAnalyticsController getAnalytics;
|
||||
late PutAnalyticsController putAnalytics;
|
||||
late MessageDataController messageData;
|
||||
|
||||
late SubscriptionController subscriptionController;
|
||||
late TextToSpeechController textToSpeech;
|
||||
late SpeechToTextController speechToText;
|
||||
|
|
@ -86,7 +83,6 @@ class PangeaController {
|
|||
permissionsController = PermissionsController(this);
|
||||
getAnalytics = GetAnalyticsController(this);
|
||||
putAnalytics = PutAnalyticsController(this);
|
||||
messageData = MessageDataController(this);
|
||||
subscriptionController = SubscriptionController(this);
|
||||
textToSpeech = TextToSpeechController(this);
|
||||
speechToText = SpeechToTextController(this);
|
||||
|
|
|
|||
|
|
@ -131,8 +131,11 @@ class PangeaAnyState {
|
|||
}
|
||||
}
|
||||
|
||||
RenderBox? getRenderBox(String key) =>
|
||||
layerLinkAndKey(key).key.currentContext?.findRenderObject() as RenderBox?;
|
||||
RenderBox? getRenderBox(String key) {
|
||||
final box = layerLinkAndKey(key).key.currentContext?.findRenderObject()
|
||||
as RenderBox?;
|
||||
return box?.hasSize == true ? box : null;
|
||||
}
|
||||
|
||||
bool isOverlayOpen(RegExp regex) {
|
||||
return entries.any(
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
|
@ -13,18 +12,14 @@ import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
|||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/emojis/emoji_stack.dart';
|
||||
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
|
||||
import 'package:fluffychat/pangea/message_token_text/token_practice_button.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_icon.dart';
|
||||
import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class ConstructIdentifier {
|
||||
|
|
@ -143,8 +138,6 @@ class ConstructIdentifier {
|
|||
);
|
||||
}
|
||||
|
||||
String get partialKey => "$lemma-${type.string}";
|
||||
|
||||
ConstructUses get constructUses =>
|
||||
MatrixState.pangeaController.getAnalytics.constructListModel
|
||||
.getConstructUses(
|
||||
|
|
@ -159,8 +152,6 @@ class ConstructIdentifier {
|
|||
|
||||
List<String> get userSetEmoji => userLemmaInfo?.emojis ?? [];
|
||||
|
||||
String? get userSetMeaning => userLemmaInfo?.meaning;
|
||||
|
||||
UserSetLemmaInfo? get userLemmaInfo {
|
||||
switch (type) {
|
||||
case ConstructTypeEnum.vocab:
|
||||
|
|
@ -265,116 +256,6 @@ class ConstructIdentifier {
|
|||
_lemmaInfoRequest,
|
||||
);
|
||||
|
||||
LemmaInfoResponse? getLemmaInfoCached([
|
||||
String? lemmaLang,
|
||||
String? userl1,
|
||||
]) =>
|
||||
LemmaInfoRepo.getCached(
|
||||
_lemmaInfoRequest,
|
||||
);
|
||||
|
||||
bool get isContentWord =>
|
||||
PartOfSpeechEnumExtensions.fromString(category)?.isContentWord ?? false;
|
||||
|
||||
/// [form] should be passed if available and is required for morphId
|
||||
bool isActivityProbablyLevelAppropriate(ActivityTypeEnum a, String? form) {
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
final double contentModifier = isContentWord ? 0.5 : 1;
|
||||
if (daysSinceLastEligibleUseForMeaning <
|
||||
3 * constructUses.points * contentModifier) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
case ActivityTypeEnum.emoji:
|
||||
return userSetEmoji.length < maxEmojisPerLemma;
|
||||
case ActivityTypeEnum.morphId:
|
||||
if (form == null) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
e: Exception(
|
||||
"form is null in isActivityProbablyLevelAppropriate for morphId",
|
||||
),
|
||||
data: {
|
||||
"activity": a,
|
||||
"construct": toJson(),
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
final uses = constructUses.uses
|
||||
.where((u) => u.form == form)
|
||||
.map((u) => u.timeStamp)
|
||||
.toList();
|
||||
|
||||
if (uses.isEmpty) return true;
|
||||
|
||||
final lastUsed = uses.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
|
||||
return DateTime.now().difference(lastUsed).inDays >
|
||||
1 * constructUses.points;
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
final pos = PartOfSpeechEnumExtensions.fromString(lemma) ??
|
||||
PartOfSpeechEnumExtensions.fromString(category);
|
||||
|
||||
if (pos == null) {
|
||||
debugger(when: kDebugMode);
|
||||
return false;
|
||||
}
|
||||
|
||||
return pos.canBeHeard;
|
||||
default:
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
e: Exception(
|
||||
"Activity type $a not handled in ConstructIdentifier.isActivityProbablyLevelAppropriate",
|
||||
),
|
||||
data: {
|
||||
"activity": a,
|
||||
"construct": toJson(),
|
||||
},
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// days since last eligible use for meaning
|
||||
/// this is the number of days since the last time the user used this word
|
||||
/// in a way that would engage with the meaning of the word
|
||||
/// importantly, this excludes emoji activities
|
||||
/// we want users to be able to do an emoji activity as a ramp up to
|
||||
/// a word meaning activity
|
||||
int get daysSinceLastEligibleUseForMeaning {
|
||||
final times = constructUses.uses
|
||||
.where(
|
||||
(u) =>
|
||||
u.useType.sentByUser ||
|
||||
ActivityTypeEnum.wordMeaning.associatedUseTypes
|
||||
.contains(u.useType) ||
|
||||
ActivityTypeEnum.messageMeaning.associatedUseTypes
|
||||
.contains(u.useType),
|
||||
)
|
||||
.map((u) => u.timeStamp)
|
||||
.toList();
|
||||
|
||||
if (times.isEmpty) return 1000;
|
||||
|
||||
// return the most recent timestamp
|
||||
final last = times.reduce((a, b) => a.isAfter(b) ? a : b);
|
||||
|
||||
return DateTime.now().difference(last).inDays;
|
||||
}
|
||||
|
||||
Widget get visual {
|
||||
switch (type) {
|
||||
case ConstructTypeEnum.vocab:
|
||||
return EmojiStack(emoji: userSetEmoji);
|
||||
case ConstructTypeEnum.morph:
|
||||
return MorphIcon(
|
||||
morphFeature: MorphFeaturesEnumExtension.fromString(category),
|
||||
morphTag: lemma,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,6 @@ import 'dart:async';
|
|||
import 'package:async/async.dart';
|
||||
import 'package:matrix/matrix.dart' hide Result;
|
||||
|
||||
import 'package:fluffychat/pangea/common/controllers/base_controller.dart';
|
||||
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
|
|
@ -21,16 +19,10 @@ import 'package:fluffychat/widgets/matrix.dart';
|
|||
|
||||
// TODO - make this static and take it out of the _pangeaController
|
||||
// will need to pass accessToken to the requests
|
||||
class MessageDataController extends BaseController {
|
||||
late PangeaController _pangeaController;
|
||||
|
||||
MessageDataController(PangeaController pangeaController) {
|
||||
_pangeaController = pangeaController;
|
||||
}
|
||||
|
||||
class MessageDataController {
|
||||
/// get tokens from the server
|
||||
/// if repEventId is not null, send the tokens to the room
|
||||
Future<Result<TokensResponseModel>> getTokens({
|
||||
static Future<Result<TokensResponseModel>> getTokens({
|
||||
required String? repEventId,
|
||||
required TokensRequestModel req,
|
||||
required Room? room,
|
||||
|
|
@ -67,18 +59,18 @@ class MessageDataController extends BaseController {
|
|||
/// if in cache, return from cache
|
||||
/// if not in cache, get from server
|
||||
/// send the translation to the room as a representation event
|
||||
Future<PangeaRepresentation> getPangeaRepresentation({
|
||||
static Future<PangeaRepresentation> getPangeaRepresentation({
|
||||
required FullTextTranslationRequestModel req,
|
||||
required Event messageEvent,
|
||||
}) =>
|
||||
_getPangeaRepresentation(req: req, messageEvent: messageEvent);
|
||||
|
||||
Future<PangeaRepresentation> _getPangeaRepresentation({
|
||||
static Future<PangeaRepresentation> _getPangeaRepresentation({
|
||||
required FullTextTranslationRequestModel req,
|
||||
required Event messageEvent,
|
||||
}) async {
|
||||
final res = await FullTextTranslationRepo.get(
|
||||
_pangeaController.userController.accessToken,
|
||||
MatrixState.pangeaController.userController.accessToken,
|
||||
req,
|
||||
);
|
||||
|
||||
|
|
@ -111,13 +103,13 @@ class MessageDataController extends BaseController {
|
|||
return rep;
|
||||
}
|
||||
|
||||
Future<String?> getPangeaRepresentationEvent({
|
||||
static Future<String?> getPangeaRepresentationEvent({
|
||||
required FullTextTranslationRequestModel req,
|
||||
required PangeaMessageEvent messageEvent,
|
||||
bool originalSent = false,
|
||||
}) async {
|
||||
final res = await FullTextTranslationRepo.get(
|
||||
_pangeaController.userController.accessToken,
|
||||
MatrixState.pangeaController.userController.accessToken,
|
||||
req,
|
||||
);
|
||||
|
||||
|
|
@ -154,7 +146,7 @@ class MessageDataController extends BaseController {
|
|||
}
|
||||
}
|
||||
|
||||
Future<SttTranslationModel> getSttTranslation({
|
||||
static Future<SttTranslationModel> getSttTranslation({
|
||||
required String? repEventId,
|
||||
required FullTextTranslationRequestModel req,
|
||||
required Room? room,
|
||||
|
|
@ -165,13 +157,13 @@ class MessageDataController extends BaseController {
|
|||
room: room,
|
||||
);
|
||||
|
||||
Future<SttTranslationModel> _getSttTranslation({
|
||||
static Future<SttTranslationModel> _getSttTranslation({
|
||||
required String? repEventId,
|
||||
required FullTextTranslationRequestModel req,
|
||||
required Room? room,
|
||||
}) async {
|
||||
final res = await FullTextTranslationRepo.get(
|
||||
_pangeaController.userController.accessToken,
|
||||
MatrixState.pangeaController.userController.accessToken,
|
||||
req,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ import 'package:sentry_flutter/sentry_flutter.dart';
|
|||
|
||||
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
|
||||
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
|
||||
import 'package:fluffychat/pangea/events/controllers/message_data_controller.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_representation_event.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/events/models/representation_content_model.dart';
|
||||
import 'package:fluffychat/pangea/events/models/stt_translation_model.dart';
|
||||
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
|
||||
|
|
@ -19,12 +19,9 @@ import 'package:fluffychat/pangea/events/repo/language_detection_repo.dart';
|
|||
import 'package:fluffychat/pangea/events/repo/language_detection_request.dart';
|
||||
import 'package:fluffychat/pangea/events/repo/language_detection_response.dart';
|
||||
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/spaces/models/space_model.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/audio_encoding_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
|
||||
import 'package:fluffychat/pangea/translation/full_text_translation_request_model.dart';
|
||||
|
|
@ -562,8 +559,7 @@ class PangeaMessageEvent {
|
|||
// clear representations cache so the new representation event can be added when next requested
|
||||
_representations = null;
|
||||
|
||||
return MatrixState.pangeaController.messageData
|
||||
.getPangeaRepresentationEvent(
|
||||
return MessageDataController.getPangeaRepresentationEvent(
|
||||
req: FullTextTranslationRequestModel(
|
||||
text: originalSent?.content.text ?? _latestEdit.body,
|
||||
srcLang: originalSent?.langCode,
|
||||
|
|
@ -607,7 +603,7 @@ class PangeaMessageEvent {
|
|||
|
||||
// clear representations cache so the new representation event can be added when next requested
|
||||
_representations = null;
|
||||
return MatrixState.pangeaController.messageData.getPangeaRepresentation(
|
||||
return MessageDataController.getPangeaRepresentation(
|
||||
req: FullTextTranslationRequestModel(
|
||||
text: includedIT ? originalWrittenContent : messageDisplayText,
|
||||
srcLang: srcLang,
|
||||
|
|
@ -642,11 +638,6 @@ class PangeaMessageEvent {
|
|||
String? get l1Code =>
|
||||
MatrixState.pangeaController.languageController.userL1?.langCode;
|
||||
|
||||
/// Should almost always be true. Useful in the case that the message
|
||||
/// display rep has the langCode "unk"
|
||||
bool get messageDisplayLangIsL2 =>
|
||||
messageDisplayLangCode.split("-")[0] == l2Code?.split("-")[0];
|
||||
|
||||
String get messageDisplayLangCode {
|
||||
if (isAudioMessage) {
|
||||
final stt = getSpeechToTextLocal();
|
||||
|
|
@ -672,67 +663,6 @@ class PangeaMessageEvent {
|
|||
/// it returns the message body.
|
||||
String get messageDisplayText => messageDisplayRepresentation?.text ?? body;
|
||||
|
||||
/// Returns a list of all [PracticeActivityEvent] objects
|
||||
/// associated with this message event.
|
||||
List<PracticeActivityEvent> get _practiceActivityEvents {
|
||||
final List<Event> events = _latestEdit
|
||||
.aggregatedEvents(
|
||||
timeline,
|
||||
PangeaEventTypes.pangeaActivity,
|
||||
)
|
||||
.where((event) => !event.redacted)
|
||||
.toList();
|
||||
|
||||
final List<PracticeActivityEvent> practiceEvents = [];
|
||||
for (final event in events) {
|
||||
try {
|
||||
practiceEvents.add(
|
||||
PracticeActivityEvent(
|
||||
timeline: timeline,
|
||||
event: event,
|
||||
),
|
||||
);
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(e: e, s: s, data: event.toJson());
|
||||
}
|
||||
}
|
||||
return practiceEvents;
|
||||
}
|
||||
|
||||
/// Returns a list of [PracticeActivityEvent] objects for the given [langCode].
|
||||
List<PracticeActivityEvent> practiceActivitiesByLangCode(
|
||||
String langCode, {
|
||||
bool debug = false,
|
||||
}) =>
|
||||
_practiceActivityEvents
|
||||
.where(
|
||||
(event) =>
|
||||
event.practiceActivity.langCode.split("-")[0] ==
|
||||
langCode.split("")[0],
|
||||
)
|
||||
.toList();
|
||||
|
||||
/// Returns a list of [PracticeActivityEvent] for the user's active l2.
|
||||
List<PracticeActivityEvent> get practiceActivities =>
|
||||
l2Code == null ? [] : practiceActivitiesByLangCode(l2Code!);
|
||||
|
||||
bool shouldDoActivity({
|
||||
required PangeaToken? token,
|
||||
required ActivityTypeEnum a,
|
||||
required MorphFeaturesEnum? feature,
|
||||
required String? tag,
|
||||
}) {
|
||||
if (!messageDisplayLangIsL2 || token == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return token.shouldDoActivity(
|
||||
a: a,
|
||||
feature: feature,
|
||||
tag: tag,
|
||||
);
|
||||
}
|
||||
|
||||
TextDirection get textDirection =>
|
||||
PLanguageStore.rtlLanguageCodes.contains(messageDisplayLangCode)
|
||||
? TextDirection.rtl
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
|||
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
|
||||
import 'package:fluffychat/pangea/events/controllers/message_data_controller.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_choreo_event.dart';
|
||||
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
|
||||
import 'package:fluffychat/pangea/events/models/language_detection_model.dart';
|
||||
|
|
@ -112,7 +113,7 @@ class RepresentationEvent {
|
|||
),
|
||||
);
|
||||
}
|
||||
final res = await MatrixState.pangeaController.messageData.getTokens(
|
||||
final res = await MessageDataController.getTokens(
|
||||
repEventId: _event?.eventId,
|
||||
room: _event?.room ?? parentMessageEvent.room,
|
||||
req: TokensRequestModel(
|
||||
|
|
@ -151,7 +152,7 @@ class RepresentationEvent {
|
|||
return;
|
||||
}
|
||||
|
||||
await MatrixState.pangeaController.messageData.getTokens(
|
||||
await MessageDataController.getTokens(
|
||||
repEventId: repEventID,
|
||||
room: room,
|
||||
req: TokensRequestModel(
|
||||
|
|
@ -216,7 +217,7 @@ class RepresentationEvent {
|
|||
final local = sttTranslations.firstWhereOrNull((t) => t.langCode == userL1);
|
||||
if (local != null) return local;
|
||||
|
||||
return MatrixState.pangeaController.messageData.getSttTranslation(
|
||||
return MessageDataController.getSttTranslation(
|
||||
repEventId: _event?.eventId,
|
||||
room: _event?.room,
|
||||
req: FullTextTranslationRequestModel(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
|
|||
import 'package:fluffychat/pangea/events/models/representation_content_model.dart';
|
||||
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_record.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
|
||||
|
||||
|
|
@ -36,8 +35,6 @@ extension PangeaEvent on Event {
|
|||
return ChoreoRecordModel.fromJson(json) as V;
|
||||
case PangeaEventTypes.pangeaActivity:
|
||||
return PracticeActivityModel.fromJson(json) as V;
|
||||
case PangeaEventTypes.activityRecord:
|
||||
return PracticeRecord.fromJson(json) as V;
|
||||
default:
|
||||
debugger(when: kDebugMode);
|
||||
throw Exception("$type events do not have pangea content");
|
||||
|
|
|
|||
|
|
@ -4,22 +4,17 @@ import 'package:flutter/foundation.dart';
|
|||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_form.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_level_enum.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_repo.dart';
|
||||
import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import '../../common/constants/model_keys.dart';
|
||||
|
|
@ -130,19 +125,8 @@ class PangeaToken {
|
|||
/// alias for the end of the token ie offset + length
|
||||
int get end => text.offset + text.length;
|
||||
|
||||
bool get isContentWord => vocabConstructID.isContentWord;
|
||||
|
||||
String get analyticsDebugPrint =>
|
||||
"content: ${text.content} isContentWord: $isContentWord vocab_construct_xp: ${vocabConstruct.points} daysSincelastUseInWordMeaning ${daysSinceLastUseByType(ActivityTypeEnum.wordMeaning, null)}";
|
||||
|
||||
bool get canBeDefined =>
|
||||
PartOfSpeechEnumExtensions.fromString(pos)?.canBeDefined ?? false;
|
||||
|
||||
bool get canBeHeard =>
|
||||
PartOfSpeechEnumExtensions.fromString(pos)?.canBeHeard ?? false;
|
||||
|
||||
/// Given a [type] and [metadata], returns a [OneConstructUse] for this lemma
|
||||
OneConstructUse toVocabUse(
|
||||
OneConstructUse _toVocabUse(
|
||||
ConstructUseTypeEnum type,
|
||||
ConstructUseMetaData metadata,
|
||||
int xp,
|
||||
|
|
@ -166,7 +150,7 @@ class PangeaToken {
|
|||
final List<OneConstructUse> uses = [];
|
||||
if (!lemma.saveVocab) return uses;
|
||||
|
||||
uses.add(toVocabUse(type, metadata, xp));
|
||||
uses.add(_toVocabUse(type, metadata, xp));
|
||||
for (final morphFeature in morph.keys) {
|
||||
uses.add(
|
||||
OneConstructUse(
|
||||
|
|
@ -184,150 +168,6 @@ class PangeaToken {
|
|||
return uses;
|
||||
}
|
||||
|
||||
bool isActivityBasicallyEligible(
|
||||
ActivityTypeEnum a, [
|
||||
MorphFeaturesEnum? morphFeature,
|
||||
String? morphTag,
|
||||
]) {
|
||||
if (!lemma.saveVocab) {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return canBeDefined;
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
return lemma.saveVocab &&
|
||||
text.content.toLowerCase() != lemma.text.toLowerCase();
|
||||
case ActivityTypeEnum.emoji:
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
return true;
|
||||
case ActivityTypeEnum.morphId:
|
||||
return morph.isNotEmpty;
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return canBeHeard;
|
||||
}
|
||||
}
|
||||
|
||||
// bool _didActivity(
|
||||
// ActivityTypeEnum a, [
|
||||
// String? morphFeature,
|
||||
// String? morphTag,
|
||||
// ]) {
|
||||
// if ((morphFeature == null || morphTag == null) &&
|
||||
// a == ActivityTypeEnum.morphId) {
|
||||
// debugger(when: kDebugMode);
|
||||
// return true;
|
||||
// }
|
||||
|
||||
// switch (a) {
|
||||
// case ActivityTypeEnum.wordMeaning:
|
||||
// case ActivityTypeEnum.wordFocusListening:
|
||||
// case ActivityTypeEnum.hiddenWordListening:
|
||||
// case ActivityTypeEnum.lemmaId:
|
||||
// case ActivityTypeEnum.emoji:
|
||||
// case ActivityTypeEnum.messageMeaning:
|
||||
// return vocabConstruct.uses
|
||||
// .map((u) => u.useType)
|
||||
// .any((u) => a.associatedUseTypes.contains(u));
|
||||
// case ActivityTypeEnum.morphId:
|
||||
// return morph.entries
|
||||
// .map((e) => morphConstruct(morphFeature!, morphTag!).uses)
|
||||
// .expand((e) => e)
|
||||
// .any(
|
||||
// (u) =>
|
||||
// a.associatedUseTypes.contains(u.useType) &&
|
||||
// u.form == text.content,
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
|
||||
bool didActivitySuccessfully(
|
||||
ActivityTypeEnum a, [
|
||||
MorphFeaturesEnum? morphFeature,
|
||||
String? morphTag,
|
||||
]) {
|
||||
if ((morphFeature == null || morphTag == null) &&
|
||||
a == ActivityTypeEnum.morphId) {
|
||||
debugger(when: kDebugMode);
|
||||
return true;
|
||||
}
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
case ActivityTypeEnum.emoji:
|
||||
return vocabConstruct.uses
|
||||
.map((u) => u.useType)
|
||||
.any((u) => u == a.correctUse);
|
||||
// Note that it matters less if they did morphId in general, than if they did it with the particular feature
|
||||
case ActivityTypeEnum.morphId:
|
||||
// TODO: investigate if we take out condition "|| morphTag == null", will we get the expected number of morph activities?
|
||||
if (morphFeature == null || morphTag == null) {
|
||||
debugger(when: kDebugMode);
|
||||
return false;
|
||||
}
|
||||
return morphConstruct(morphFeature)?.uses.any(
|
||||
(u) => u.useType == a.correctUse && u.form == text.content,
|
||||
) ??
|
||||
false;
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
m: "should not call didActivitySuccessfully for ActivityTypeEnum.messageMeaning",
|
||||
data: toJson(),
|
||||
);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool _isActivityProbablyLevelAppropriate(
|
||||
ActivityTypeEnum a, [
|
||||
MorphFeaturesEnum? morphFeature,
|
||||
]) {
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return vocabConstructID.isActivityProbablyLevelAppropriate(
|
||||
a,
|
||||
text.content,
|
||||
);
|
||||
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return !didActivitySuccessfully(a) ||
|
||||
daysSinceLastUseByType(a, null) > 30;
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return daysSinceLastUseByType(a, null) > 7;
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
return false;
|
||||
// disabling lemma activities for now
|
||||
// It has 2 purposes:
|
||||
// • learning value
|
||||
// • triangulating our determination of the lemma with AI plus user verification.
|
||||
// However, displaying the lemma during the meaning activity helps
|
||||
// disambiguate what the meaning activity is about. This is probably more valuable than the
|
||||
// lemma activity itself. The piping for the lemma activity will stay there if we want to turn
|
||||
// it back on, maybe in select instances.
|
||||
// return _didActivitySuccessfully(ActivityTypeEnum.wordMeaning) &&
|
||||
// daysSinceLastUseByType(a) > 7;
|
||||
case ActivityTypeEnum.emoji:
|
||||
return vocabConstructID.isActivityProbablyLevelAppropriate(
|
||||
a,
|
||||
text.content,
|
||||
);
|
||||
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
return true;
|
||||
case ActivityTypeEnum.morphId:
|
||||
return morphFeature != null
|
||||
? morphIdByFeature(morphFeature)
|
||||
?.isActivityProbablyLevelAppropriate(a, text.content) ??
|
||||
false
|
||||
: false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Safely get morph tag for a given feature without regard for case
|
||||
String? getMorphTag(MorphFeaturesEnum feature) {
|
||||
// if the morph contains the feature, return it
|
||||
|
|
@ -336,16 +176,6 @@ class PangeaToken {
|
|||
return null;
|
||||
}
|
||||
|
||||
// maybe for every 5 points of xp for a particular activity, increment the days between uses by 2
|
||||
bool shouldDoActivity({
|
||||
required ActivityTypeEnum a,
|
||||
required MorphFeaturesEnum? feature,
|
||||
required String? tag,
|
||||
}) {
|
||||
return isActivityBasicallyEligible(a, feature, tag) &&
|
||||
_isActivityProbablyLevelAppropriate(a, feature);
|
||||
}
|
||||
|
||||
ConstructUses get vocabConstruct =>
|
||||
MatrixState.pangeaController.getAnalytics.constructListModel
|
||||
.getConstructUses(
|
||||
|
|
@ -358,9 +188,6 @@ class PangeaToken {
|
|||
uses: [],
|
||||
);
|
||||
|
||||
ConstructUses? morphConstruct(MorphFeaturesEnum morphFeature) =>
|
||||
morphIdByFeature(morphFeature)?.constructUses;
|
||||
|
||||
ConstructIdentifier? morphIdByFeature(MorphFeaturesEnum feature) {
|
||||
final tag = getMorphTag(feature);
|
||||
if (tag == null) return null;
|
||||
|
|
@ -405,36 +232,6 @@ class PangeaToken {
|
|||
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.name,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
List<ConstructUses> get constructs => _constructIDs
|
||||
.map(
|
||||
(id) => MatrixState.pangeaController.getAnalytics.constructListModel
|
||||
.getConstructUses(id),
|
||||
)
|
||||
.where((construct) => construct != null)
|
||||
.cast<ConstructUses>()
|
||||
.toList();
|
||||
|
||||
ConstructIdentifier get vocabConstructID => ConstructIdentifier(
|
||||
lemma: lemma.text,
|
||||
type: ConstructTypeEnum.vocab,
|
||||
|
|
@ -447,23 +244,7 @@ class PangeaToken {
|
|||
Future<void> setEmoji(List<String> emojis) =>
|
||||
vocabConstructID.setUserLemmaInfo(UserSetLemmaInfo(emojis: emojis));
|
||||
|
||||
/// [getEmoji] gets the emoji for the lemma
|
||||
/// NOTE: assumes that the language of the lemma is the same as the user's current l2
|
||||
List<String> getEmoji() => vocabConstructID.userSetEmoji;
|
||||
|
||||
String get xpEmoji => vocabConstruct.xpEmoji;
|
||||
|
||||
ConstructLevelEnum get lemmaXPCategory {
|
||||
if (vocabConstruct.points >= AnalyticsConstants.xpForFlower) {
|
||||
return ConstructLevelEnum.flowers;
|
||||
} else if (vocabConstruct.points >= AnalyticsConstants.xpForGreens) {
|
||||
return ConstructLevelEnum.greens;
|
||||
} else {
|
||||
return ConstructLevelEnum.seeds;
|
||||
}
|
||||
}
|
||||
|
||||
List<String> morphActivityDistractors(
|
||||
Set<String> morphActivityDistractors(
|
||||
MorphFeaturesEnum morphFeature,
|
||||
String morphTag,
|
||||
) {
|
||||
|
|
@ -477,67 +258,9 @@ class PangeaToken {
|
|||
.toList();
|
||||
|
||||
possibleDistractors.shuffle();
|
||||
return possibleDistractors.take(numberOfMorphDistractors).toList();
|
||||
return possibleDistractors.take(numberOfMorphDistractors).toSet();
|
||||
}
|
||||
|
||||
/// initial default input mode for a token
|
||||
MessageMode get modeForToken {
|
||||
// if (getEmoji() == null) {
|
||||
// return MessageMode.wordEmoji;
|
||||
// }
|
||||
|
||||
if (shouldDoActivity(
|
||||
a: ActivityTypeEnum.wordMeaning,
|
||||
feature: null,
|
||||
tag: null,
|
||||
)) {
|
||||
return MessageMode.wordMeaning;
|
||||
}
|
||||
|
||||
// final String? morph = nextMorphFeatureEligibleForActivity;
|
||||
// if (morph != null) {
|
||||
// debugPrint("should do morph activity for ${text.content}");
|
||||
// return MessageMode.wordMorph;
|
||||
// }
|
||||
|
||||
return MessageMode.wordZoom;
|
||||
}
|
||||
|
||||
List<MorphFeaturesEnum> get allMorphFeatures => morph.keys.toList();
|
||||
|
||||
/// cycle through morphs to get the next one where should do morph activity is true
|
||||
/// if none are found, return null
|
||||
MorphFeaturesEnum? get nextMorphFeatureEligibleForActivity {
|
||||
for (final m in morph.entries) {
|
||||
if (shouldDoActivity(
|
||||
a: ActivityTypeEnum.morphId,
|
||||
feature: m.key,
|
||||
tag: m.value,
|
||||
)) {
|
||||
return m.key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
bool get doesLemmaTextMatchTokenText {
|
||||
return lemma.text.toLowerCase() == text.content.toLowerCase();
|
||||
}
|
||||
|
||||
bool shouldDoActivityByMessageMode(MessageMode mode) {
|
||||
// debugPrint("should do activity for ${text.content} in $mode");
|
||||
return mode.associatedActivityType != null
|
||||
? shouldDoActivity(
|
||||
a: mode.associatedActivityType!,
|
||||
feature: null,
|
||||
tag: null,
|
||||
)
|
||||
: false;
|
||||
}
|
||||
|
||||
List<ConstructIdentifier> get allConstructIds => _constructIDs;
|
||||
|
||||
List<ConstructIdentifier> get morphsBasicallyEligibleForPracticeByPriority =>
|
||||
MorphFeaturesEnumExtension.eligibleForPractice.where((f) {
|
||||
return morph.containsKey(f);
|
||||
|
|
@ -549,14 +272,6 @@ class PangeaToken {
|
|||
);
|
||||
}).toList();
|
||||
|
||||
bool hasMorph(ConstructIdentifier cId) {
|
||||
return morph.entries.any(
|
||||
(e) =>
|
||||
e.key.name == cId.lemma.toLowerCase() &&
|
||||
e.value.toString().toLowerCase() == cId.category.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
/// [0,infinity) - a higher number means higher priority
|
||||
int activityPriorityScore(
|
||||
ActivityTypeEnum a,
|
||||
|
|
@ -566,5 +281,14 @@ class PangeaToken {
|
|||
(vocabConstructID.isContentWord ? 10 : 9);
|
||||
}
|
||||
|
||||
bool eligibleForPractice(ActivityTypeEnum activityType) {
|
||||
switch (activityType) {
|
||||
case ActivityTypeEnum.emoji:
|
||||
return lemma.saveVocab && vocabConstructID.isContentWord;
|
||||
default:
|
||||
return lemma.saveVocab;
|
||||
}
|
||||
}
|
||||
|
||||
String get uniqueId => "${text.content}::${text.offset}";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import 'dart:developer';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
|
@ -9,7 +7,7 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||
import 'package:shimmer/shimmer.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/message_token_text/dotted_border_painter.dart';
|
||||
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
|
||||
|
|
@ -19,357 +17,130 @@ import 'package:fluffychat/pangea/practice_activities/practice_choice.dart';
|
|||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart';
|
||||
import 'package:fluffychat/widgets/hover_builder.dart';
|
||||
|
||||
const double tokenButtonHeight = 40.0;
|
||||
const double tokenButtonDefaultFontSize = 10;
|
||||
const int maxEmojisPerLemma = 1;
|
||||
const double estimatedEmojiWidthRatio = 2;
|
||||
|
||||
class TokenPracticeButton extends StatefulWidget {
|
||||
final MessageOverlayController? overlayController;
|
||||
class TokenPracticeButton extends StatelessWidget {
|
||||
final PracticeController controller;
|
||||
final PangeaToken token;
|
||||
final TextStyle textStyle;
|
||||
final double width;
|
||||
final bool animateIn;
|
||||
final Color textColor;
|
||||
|
||||
const TokenPracticeButton({
|
||||
super.key,
|
||||
required this.overlayController,
|
||||
required this.controller,
|
||||
required this.token,
|
||||
required this.textStyle,
|
||||
required this.width,
|
||||
required this.textColor,
|
||||
this.animateIn = false,
|
||||
});
|
||||
|
||||
@override
|
||||
TokenPracticeButtonState createState() => TokenPracticeButtonState();
|
||||
}
|
||||
|
||||
class TokenPracticeButtonState extends State<TokenPracticeButton>
|
||||
with TickerProviderStateMixin {
|
||||
AnimationController? _controller;
|
||||
Animation<double>? _heightAnimation;
|
||||
|
||||
// New controller and animation for icon size
|
||||
AnimationController? _iconSizeController;
|
||||
Animation<double>? _iconSizeAnimation;
|
||||
|
||||
bool _isHovered = false;
|
||||
bool _isSelected = false;
|
||||
bool _finishedInitialAnimation = false;
|
||||
bool _wasEmpty = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(
|
||||
milliseconds: AppConfig.overlayAnimationDuration,
|
||||
),
|
||||
);
|
||||
|
||||
_heightAnimation = Tween<double>(
|
||||
begin: 0,
|
||||
end: tokenButtonHeight,
|
||||
).animate(CurvedAnimation(parent: _controller!, curve: Curves.easeOut));
|
||||
|
||||
// Initialize the new icon size controller and animation
|
||||
_iconSizeController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
|
||||
_iconSizeAnimation = Tween<double>(
|
||||
begin: 24, // Default icon size
|
||||
end: 30, // Enlarged icon size
|
||||
).animate(
|
||||
CurvedAnimation(parent: _iconSizeController!, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_setSelected(); // Call _setSelected after initializing _iconSizeController
|
||||
|
||||
_wasEmpty = _isEmpty;
|
||||
|
||||
if (!_isEmpty) {
|
||||
_controller?.forward().then((_) {
|
||||
if (mounted) setState(() => _finishedInitialAnimation = true);
|
||||
});
|
||||
} else {
|
||||
setState(() => _finishedInitialAnimation = true);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant TokenPracticeButton oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
_setSelected();
|
||||
if (_isEmpty != _wasEmpty) {
|
||||
if (_isEmpty && _animate) {
|
||||
_controller?.reverse();
|
||||
} else if (!_isEmpty && _animate) {
|
||||
_controller?.forward();
|
||||
}
|
||||
setState(() => _wasEmpty = _isEmpty);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller?.dispose();
|
||||
_iconSizeController?.dispose(); // Dispose the new controller
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
PracticeTarget? get _activity =>
|
||||
widget.overlayController?.practiceTargetForToken(widget.token);
|
||||
|
||||
bool get _animate => widget.animateIn || _finishedInitialAnimation;
|
||||
|
||||
bool get _isActivityCompleteOrNullForToken =>
|
||||
_activity?.isCompleteByToken(
|
||||
widget.token,
|
||||
_activity!.morphFeature,
|
||||
) ==
|
||||
true;
|
||||
|
||||
void _setSelected() {
|
||||
final selected =
|
||||
widget.overlayController?.selectedMorph?.token == widget.token &&
|
||||
widget.overlayController?.selectedMorph?.morph ==
|
||||
_activity?.morphFeature;
|
||||
|
||||
if (selected != _isSelected) {
|
||||
setState(() {
|
||||
_isSelected = selected;
|
||||
});
|
||||
|
||||
_isSelected
|
||||
? _iconSizeController?.forward()
|
||||
: _iconSizeController?.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _setHovered(bool isHovered) {
|
||||
if (isHovered != _isHovered) {
|
||||
setState(() {
|
||||
_isHovered = isHovered;
|
||||
});
|
||||
|
||||
if (!_isHovered && _isSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isHovered
|
||||
? _iconSizeController?.forward()
|
||||
: _iconSizeController?.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _onMatch(PracticeChoice form) {
|
||||
if (widget.overlayController?.activity == null) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
m: "should not be in onAcceptWithDetails with null activity",
|
||||
data: {"details": form},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
widget.overlayController!.onChoiceSelect(null);
|
||||
widget.overlayController!.onMatch(widget.token, form);
|
||||
}
|
||||
|
||||
bool get _isEmpty {
|
||||
final mode = widget.overlayController?.toolbarMode;
|
||||
if (MessageMode.wordEmoji == mode &&
|
||||
widget.token.vocabConstructID.userSetEmoji.firstOrNull != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _activity == null ||
|
||||
(_isActivityCompleteOrNullForToken &&
|
||||
![MessageMode.wordEmoji, MessageMode.wordMorph].contains(mode)) ||
|
||||
(MessageMode.wordMorph == mode && _activity?.morphFeature == null);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.overlayController == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!_animate && _iconSizeAnimation != null) {
|
||||
return MessageTokenButtonContent(
|
||||
activity: _activity,
|
||||
messageMode: widget.overlayController!.toolbarMode,
|
||||
token: widget.token,
|
||||
selectedChoice: widget.overlayController?.selectedChoice,
|
||||
isActivityCompleteOrNullForToken: _isActivityCompleteOrNullForToken,
|
||||
isSelected: _isSelected,
|
||||
height: tokenButtonHeight,
|
||||
width: widget.width,
|
||||
textStyle: widget.textStyle,
|
||||
textColor: widget.textColor,
|
||||
sizeAnimation: _iconSizeAnimation!,
|
||||
onHover: _setHovered,
|
||||
onTap: () => widget.overlayController!.onMorphActivitySelect(
|
||||
MorphSelection(widget.token, _activity!.morphFeature!),
|
||||
),
|
||||
onMatch: _onMatch,
|
||||
);
|
||||
}
|
||||
|
||||
if (_heightAnimation != null && _iconSizeAnimation != null) {
|
||||
return AnimatedBuilder(
|
||||
animation: _heightAnimation!,
|
||||
builder: (context, child) {
|
||||
return MessageTokenButtonContent(
|
||||
activity: _activity,
|
||||
messageMode: widget.overlayController!.toolbarMode,
|
||||
token: widget.token,
|
||||
selectedChoice: widget.overlayController?.selectedChoice,
|
||||
isActivityCompleteOrNullForToken: _isActivityCompleteOrNullForToken,
|
||||
isSelected: _isSelected,
|
||||
height: _heightAnimation!.value,
|
||||
width: widget.width,
|
||||
textStyle: widget.textStyle,
|
||||
textColor: widget.textColor,
|
||||
sizeAnimation: _iconSizeAnimation!,
|
||||
onHover: _setHovered,
|
||||
onTap: () => widget.overlayController!.onMorphActivitySelect(
|
||||
MorphSelection(widget.token, _activity!.morphFeature!),
|
||||
),
|
||||
onMatch: _onMatch,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
}
|
||||
|
||||
class MessageTokenButtonContent extends StatelessWidget {
|
||||
final PracticeTarget? activity;
|
||||
final MessageMode messageMode;
|
||||
final PangeaToken token;
|
||||
final PracticeChoice? selectedChoice;
|
||||
|
||||
final bool isActivityCompleteOrNullForToken;
|
||||
final bool isSelected;
|
||||
final double height;
|
||||
final double width;
|
||||
final TextStyle textStyle;
|
||||
final Color textColor;
|
||||
final Animation<double> sizeAnimation;
|
||||
|
||||
final Function(bool)? onHover;
|
||||
final Function()? onTap;
|
||||
final Function(PracticeChoice)? onMatch;
|
||||
|
||||
const MessageTokenButtonContent({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.messageMode,
|
||||
required this.token,
|
||||
required this.selectedChoice,
|
||||
required this.isActivityCompleteOrNullForToken,
|
||||
required this.isSelected,
|
||||
required this.height,
|
||||
required this.width,
|
||||
required this.textStyle,
|
||||
required this.textColor,
|
||||
required this.sizeAnimation,
|
||||
this.onHover,
|
||||
this.onTap,
|
||||
this.onMatch,
|
||||
});
|
||||
|
||||
TextStyle get _emojiStyle => TextStyle(
|
||||
fontSize: (textStyle.fontSize ?? tokenButtonDefaultFontSize) + 4,
|
||||
);
|
||||
|
||||
static final _borderRadius =
|
||||
BorderRadius.circular(AppConfig.borderRadius - 4);
|
||||
PracticeTarget? get _activity => controller.practiceTargetForToken(token);
|
||||
|
||||
bool get isActivityCompleteOrNullForToken {
|
||||
return _activity?.isCompleteByToken(
|
||||
token,
|
||||
_activity!.morphFeature,
|
||||
) ==
|
||||
true;
|
||||
}
|
||||
|
||||
bool get _isEmpty {
|
||||
final mode = controller.practiceMode;
|
||||
if (MessageMode.wordEmoji == mode &&
|
||||
token.vocabConstructID.userSetEmoji.firstOrNull != null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _activity == null ||
|
||||
(isActivityCompleteOrNullForToken &&
|
||||
![MessageMode.wordEmoji, MessageMode.wordMorph].contains(mode)) ||
|
||||
(MessageMode.wordMorph == mode && _activity?.morphFeature == null);
|
||||
}
|
||||
|
||||
bool get _isSelected =>
|
||||
controller.selectedMorph?.token == token &&
|
||||
controller.selectedMorph?.morph == _activity?.morphFeature;
|
||||
|
||||
void _onMatch(PracticeChoice form) {
|
||||
controller.onChoiceSelect(null);
|
||||
controller.onMatch(token, form);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isActivityCompleteOrNullForToken || activity == null) {
|
||||
if (MessageMode.wordEmoji == messageMode) {
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: Text(
|
||||
activity?.record.responses
|
||||
.firstWhereOrNull(
|
||||
(res) =>
|
||||
res.cId == token.vocabConstructID && res.isCorrect,
|
||||
)
|
||||
?.text ??
|
||||
token.vocabConstructID.userSetEmoji.firstOrNull ??
|
||||
'',
|
||||
style: _emojiStyle,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (MessageMode.wordMorph == messageMode && activity != null) {
|
||||
final morphFeature = activity!.morphFeature!;
|
||||
final morphTag = token.morphIdByFeature(morphFeature);
|
||||
if (morphTag != null) {
|
||||
return Tooltip(
|
||||
message: getGrammarCopy(
|
||||
category: morphFeature.toShortString(),
|
||||
lemma: morphTag.lemma,
|
||||
context: context,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
child: Center(
|
||||
child: MorphIcon(
|
||||
morphFeature: morphFeature,
|
||||
morphTag: morphTag.lemma,
|
||||
),
|
||||
return ListenableBuilder(
|
||||
listenable: controller,
|
||||
builder: (context, _) {
|
||||
final practiceMode = controller.practiceMode;
|
||||
|
||||
Widget child;
|
||||
if (isActivityCompleteOrNullForToken || _activity == null) {
|
||||
child = _NoActivityContentButton(
|
||||
practiceMode: practiceMode,
|
||||
token: token,
|
||||
target: _activity,
|
||||
emojiStyle: _emojiStyle,
|
||||
);
|
||||
} else if (practiceMode == MessageMode.wordMorph) {
|
||||
child = _MorphMatchButton(
|
||||
active: _isSelected,
|
||||
textColor: textColor,
|
||||
onTap: () => controller.onSelectMorph(
|
||||
MorphSelection(
|
||||
token,
|
||||
_activity!.morphFeature!,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
child = _StandardMatchButton(
|
||||
selectedChoice: controller.selectedChoice,
|
||||
width: width,
|
||||
borderColor: textColor,
|
||||
onMatch: (choice) => _onMatch(choice),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
return SizedBox(height: height);
|
||||
}
|
||||
}
|
||||
|
||||
if (MessageMode.wordMorph == messageMode) {
|
||||
if (activity?.morphFeature == null) {
|
||||
return SizedBox(height: height);
|
||||
}
|
||||
|
||||
return InkWell(
|
||||
onHover: onHover,
|
||||
onTap: onTap,
|
||||
borderRadius: _borderRadius,
|
||||
child: SizedBox(
|
||||
height: height,
|
||||
child: Opacity(
|
||||
opacity: isSelected ? 1.0 : 0.6,
|
||||
child: AnimatedBuilder(
|
||||
animation: sizeAnimation,
|
||||
builder: (context, child) {
|
||||
return Icon(
|
||||
Symbols.toys_and_games,
|
||||
color: textColor,
|
||||
size: sizeAnimation.value, // Use the new animation
|
||||
);
|
||||
},
|
||||
),
|
||||
return AnimatedSize(
|
||||
duration: const Duration(
|
||||
milliseconds: AppConfig.overlayAnimationDuration,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
curve: Curves.easeOut,
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: _isEmpty
|
||||
? const SizedBox(height: 0)
|
||||
: SizedBox(height: tokenButtonHeight, child: child),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StandardMatchButton extends StatelessWidget {
|
||||
final PracticeChoice? selectedChoice;
|
||||
final double width;
|
||||
final Color borderColor;
|
||||
final Function(PracticeChoice choice) onMatch;
|
||||
|
||||
const _StandardMatchButton({
|
||||
required this.selectedChoice,
|
||||
required this.width,
|
||||
required this.borderColor,
|
||||
required this.onMatch,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DragTarget<PracticeChoice>(
|
||||
builder: (BuildContext context, accepted, rejected) {
|
||||
final double colorAlpha = 0.3 +
|
||||
|
|
@ -377,40 +148,136 @@ class MessageTokenButtonContent extends StatelessWidget {
|
|||
(accepted.isNotEmpty ? 0.3 : 0.0);
|
||||
|
||||
final theme = Theme.of(context);
|
||||
final borderRadius = BorderRadius.circular(AppConfig.borderRadius - 4);
|
||||
|
||||
return InkWell(
|
||||
onTap: selectedChoice != null
|
||||
? () => onMatch?.call(selectedChoice!)
|
||||
: null,
|
||||
borderRadius: _borderRadius,
|
||||
child: CustomPaint(
|
||||
painter: DottedBorderPainter(
|
||||
color: textColor.withAlpha((colorAlpha * 255).toInt()),
|
||||
borderRadius: _borderRadius,
|
||||
),
|
||||
child: Shimmer.fromColors(
|
||||
enabled: selectedChoice != null,
|
||||
baseColor: selectedChoice != null
|
||||
? AppConfig.gold.withAlpha(20)
|
||||
: Colors.transparent,
|
||||
highlightColor: selectedChoice != null
|
||||
? AppConfig.gold.withAlpha(50)
|
||||
: Colors.transparent,
|
||||
child: Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
width: max(width, 24.0),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: _borderRadius,
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: InkWell(
|
||||
onTap:
|
||||
selectedChoice != null ? () => onMatch(selectedChoice!) : null,
|
||||
borderRadius: borderRadius,
|
||||
child: CustomPaint(
|
||||
painter: DottedBorderPainter(
|
||||
color: borderColor.withAlpha((colorAlpha * 255).toInt()),
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
child: Shimmer.fromColors(
|
||||
enabled: selectedChoice != null,
|
||||
baseColor: selectedChoice != null
|
||||
? AppConfig.gold.withAlpha(20)
|
||||
: Colors.transparent,
|
||||
highlightColor: selectedChoice != null
|
||||
? AppConfig.gold.withAlpha(50)
|
||||
: Colors.transparent,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(top: 10.0),
|
||||
width: max(width, 24.0),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.surface,
|
||||
borderRadius: borderRadius,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onAcceptWithDetails: (details) => onMatch?.call(details.data),
|
||||
onAcceptWithDetails: (details) => onMatch(details.data),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MorphMatchButton extends StatelessWidget {
|
||||
final Function()? onTap;
|
||||
final bool active;
|
||||
final Color textColor;
|
||||
|
||||
const _MorphMatchButton({
|
||||
required this.active,
|
||||
required this.textColor,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
type: MaterialType.transparency,
|
||||
child: HoverBuilder(
|
||||
builder: (context, hovered) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius - 4),
|
||||
child: Opacity(
|
||||
opacity: active ? 1.0 : 0.6,
|
||||
child: AnimatedScale(
|
||||
scale: hovered || active ? 1.25 : 1.0,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
curve: FluffyThemes.animationCurve,
|
||||
child: Icon(
|
||||
Symbols.toys_and_games,
|
||||
color: textColor,
|
||||
size: 24.0,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoActivityContentButton extends StatelessWidget {
|
||||
final MessageMode practiceMode;
|
||||
final PangeaToken token;
|
||||
final PracticeTarget? target;
|
||||
final TextStyle emojiStyle;
|
||||
|
||||
const _NoActivityContentButton({
|
||||
required this.practiceMode,
|
||||
required this.token,
|
||||
required this.target,
|
||||
required this.emojiStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (practiceMode == MessageMode.wordEmoji) {
|
||||
final displayEmoji = target?.record.responses
|
||||
.firstWhereOrNull(
|
||||
(res) => res.cId == token.vocabConstructID && res.isCorrect,
|
||||
)
|
||||
?.text ??
|
||||
token.vocabConstructID.userSetEmoji.firstOrNull ??
|
||||
'';
|
||||
return Text(
|
||||
displayEmoji,
|
||||
style: emojiStyle,
|
||||
);
|
||||
}
|
||||
if (practiceMode == MessageMode.wordMorph && target != null) {
|
||||
final morphFeature = target!.morphFeature!;
|
||||
final morphTag = token.morphIdByFeature(morphFeature);
|
||||
if (morphTag != null) {
|
||||
return Tooltip(
|
||||
message: getGrammarCopy(
|
||||
category: morphFeature.toShortString(),
|
||||
lemma: morphTag.lemma,
|
||||
context: context,
|
||||
),
|
||||
child: SizedBox(
|
||||
width: 24.0,
|
||||
child: Center(
|
||||
child: MorphIcon(
|
||||
morphFeature: morphFeature,
|
||||
morphTag: morphTag.lemma,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return const SizedBox();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,7 +99,9 @@ class TokensUtil {
|
|||
|
||||
final List<PangeaTokenText> newTokens = [];
|
||||
for (final token in tokens) {
|
||||
if (!token.lemma.saveVocab || !token.isContentWord) continue;
|
||||
if (!token.lemma.saveVocab || !token.vocabConstructID.isContentWord) {
|
||||
continue;
|
||||
}
|
||||
if (token.vocabConstruct.uses.isNotEmpty) continue;
|
||||
if (newTokens.any((t) => t == token.text)) continue;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +0,0 @@
|
|||
enum ActivityDisplayInstructionsEnum { highlight, hide, nothing }
|
||||
|
||||
extension ActivityDisplayInstructionsEnumExt
|
||||
on ActivityDisplayInstructionsEnum {
|
||||
String get string => toString().split('.').last;
|
||||
}
|
||||
|
|
@ -11,23 +11,7 @@ enum ActivityTypeEnum {
|
|||
lemmaId,
|
||||
emoji,
|
||||
morphId,
|
||||
messageMeaning, // TODO: Add to L10n
|
||||
}
|
||||
|
||||
extension ActivityTypeExtension on ActivityTypeEnum {
|
||||
bool get hiddenType {
|
||||
switch (this) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
case ActivityTypeEnum.emoji:
|
||||
case ActivityTypeEnum.morphId:
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
return false;
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
messageMeaning;
|
||||
|
||||
bool get includeTTSOnClick {
|
||||
switch (this) {
|
||||
|
|
@ -172,25 +156,6 @@ extension ActivityTypeExtension on ActivityTypeEnum {
|
|||
}
|
||||
}
|
||||
|
||||
Widget? get contentChallengeWidget {
|
||||
switch (this) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return null;
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return null;
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return null;
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
return null;
|
||||
case ActivityTypeEnum.emoji:
|
||||
return null;
|
||||
case ActivityTypeEnum.morphId:
|
||||
return null;
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
return null; // TODO: Add to L10n
|
||||
}
|
||||
}
|
||||
|
||||
/// The minimum number of tokens in a message for this activity type to be available.
|
||||
/// Matching activities don't make sense for a single-word message.
|
||||
int get minTokensForMatchActivity {
|
||||
|
|
@ -206,4 +171,11 @@ extension ActivityTypeExtension on ActivityTypeEnum {
|
|||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
static List<ActivityTypeEnum> get practiceTypes => [
|
||||
ActivityTypeEnum.emoji,
|
||||
ActivityTypeEnum.wordMeaning,
|
||||
ActivityTypeEnum.wordFocusListening,
|
||||
ActivityTypeEnum.morphId,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da
|
|||
import 'package:fluffychat/pangea/practice_activities/practice_match.dart';
|
||||
|
||||
class EmojiActivityGenerator {
|
||||
Future<MessageActivityResponse> get(
|
||||
static Future<MessageActivityResponse> get(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
if (req.targetTokens.length <= 1) {
|
||||
|
|
@ -17,7 +17,7 @@ class EmojiActivityGenerator {
|
|||
return _matchActivity(req);
|
||||
}
|
||||
|
||||
Future<MessageActivityResponse> _matchActivity(
|
||||
static Future<MessageActivityResponse> _matchActivity(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
final Map<ConstructForm, List<String>> matchInfo = {};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,7 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
|
|
@ -13,14 +11,13 @@ import 'package:fluffychat/pangea/practice_activities/practice_activity_model.da
|
|||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class LemmaActivityGenerator {
|
||||
Future<MessageActivityResponse> get(
|
||||
static Future<MessageActivityResponse> get(
|
||||
MessageActivityRequest req,
|
||||
BuildContext context,
|
||||
) async {
|
||||
debugger(when: kDebugMode && req.targetTokens.length != 1);
|
||||
|
||||
final token = req.targetTokens.first;
|
||||
final List<String> choices = await lemmaActivityDistractors(token);
|
||||
final choices = await _lemmaActivityDistractors(token);
|
||||
|
||||
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
|
||||
return MessageActivityResponse(
|
||||
|
|
@ -29,16 +26,16 @@ class LemmaActivityGenerator {
|
|||
targetTokens: [token],
|
||||
langCode: req.userL2,
|
||||
multipleChoiceContent: MultipleChoiceActivity(
|
||||
question: L10n.of(context).chooseBaseForm,
|
||||
choices: choices,
|
||||
answers: [token.lemma.text],
|
||||
spanDisplayDetails: null,
|
||||
answers: {token.lemma.text},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> lemmaActivityDistractors(PangeaToken token) async {
|
||||
static Future<Set<String>> _lemmaActivityDistractors(
|
||||
PangeaToken token,
|
||||
) async {
|
||||
final List<String> lemmas = MatrixState
|
||||
.pangeaController.getAnalytics.constructListModel
|
||||
.constructList(type: ConstructTypeEnum.vocab)
|
||||
|
|
@ -58,32 +55,33 @@ class LemmaActivityGenerator {
|
|||
..sort((a, b) => distances[a]!.compareTo(distances[b]!));
|
||||
|
||||
// Take the shortest 4
|
||||
final choices = sortedLemmas.take(4).toList();
|
||||
final choices = sortedLemmas.take(4).toSet();
|
||||
if (choices.isEmpty) {
|
||||
return [token.lemma.text];
|
||||
return {token.lemma.text};
|
||||
}
|
||||
|
||||
if (!choices.contains(token.lemma.text)) {
|
||||
choices.add(token.lemma.text);
|
||||
choices.shuffle();
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
// isolate helper function
|
||||
Map<String, int> _computeDistancesInIsolate(Map<String, dynamic> params) {
|
||||
static Map<String, int> _computeDistancesInIsolate(
|
||||
Map<String, dynamic> params,
|
||||
) {
|
||||
final List<String> lemmas = params['lemmas'];
|
||||
final String target = params['target'];
|
||||
|
||||
// Calculate Levenshtein distances
|
||||
final Map<String, int> distances = {};
|
||||
for (final lemma in lemmas) {
|
||||
distances[lemma] = levenshteinDistanceSync(target, lemma);
|
||||
distances[lemma] = _levenshteinDistanceSync(target, lemma);
|
||||
}
|
||||
return distances;
|
||||
}
|
||||
|
||||
int levenshteinDistanceSync(String s, String t) {
|
||||
static int _levenshteinDistanceSync(String s, String t) {
|
||||
final int m = s.length;
|
||||
final int n = t.length;
|
||||
final List<List<int>> dp = List.generate(
|
||||
|
|
|
|||
|
|
@ -1,76 +1,14 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_form.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_info_request.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_info_response.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_match.dart';
|
||||
import 'package:fluffychat/pangea/word_bank/vocab_bank_repo.dart';
|
||||
import 'package:fluffychat/pangea/word_bank/vocab_request.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class LemmaMeaningActivityGenerator {
|
||||
Future<MessageActivityResponse> get(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
if (req.targetTokens.length == 1) {
|
||||
return _multipleChoiceActivity(req);
|
||||
} else {
|
||||
return _matchActivity(req);
|
||||
}
|
||||
}
|
||||
|
||||
Future<MessageActivityResponse> _multipleChoiceActivity(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
final ConstructIdentifier lemmaId = ConstructIdentifier(
|
||||
lemma: req.targetTokens[0].lemma.text.isNotEmpty
|
||||
? req.targetTokens[0].lemma.text
|
||||
: req.targetTokens[0].lemma.form,
|
||||
type: ConstructTypeEnum.vocab,
|
||||
category: req.targetTokens[0].pos,
|
||||
);
|
||||
|
||||
final lemmaDefReq = LemmaInfoRequest(
|
||||
lemma: lemmaId.lemma,
|
||||
partOfSpeech: lemmaId.category,
|
||||
lemmaLang: req.userL2,
|
||||
userL1: req.userL1,
|
||||
);
|
||||
|
||||
final res = await LemmaInfoRepo.get(lemmaDefReq);
|
||||
|
||||
final choices = await getDistractorMeanings(lemmaDefReq, 3);
|
||||
|
||||
if (!choices.contains(res.meaning)) {
|
||||
choices.add(res.meaning);
|
||||
choices.shuffle();
|
||||
}
|
||||
|
||||
return MessageActivityResponse(
|
||||
activity: PracticeActivityModel(
|
||||
targetTokens: req.targetTokens,
|
||||
langCode: req.userL2,
|
||||
activityType: ActivityTypeEnum.wordMeaning,
|
||||
multipleChoiceContent: MultipleChoiceActivity(
|
||||
question: L10n.of(MatrixState.pangeaController.matrixState.context)
|
||||
.whatIsMeaning(lemmaId.lemma, lemmaId.category),
|
||||
choices: choices,
|
||||
answers: [res.meaning],
|
||||
spanDisplayDetails: null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<MessageActivityResponse> _matchActivity(
|
||||
static Future<MessageActivityResponse> get(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
final List<Future<LemmaInfoResponse>> lemmaInfoFutures = req.targetTokens
|
||||
|
|
@ -96,46 +34,4 @@ class LemmaMeaningActivityGenerator {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// From the cache, get a random set of cached definitions that are not for a specific lemma
|
||||
static Future<List<String>> getDistractorMeanings(
|
||||
LemmaInfoRequest req,
|
||||
int count,
|
||||
) async {
|
||||
final eligible = await VocabRepo.getSemanticallySimilarWords(
|
||||
VocabRequest(
|
||||
langCode: req.lemmaLang,
|
||||
level: MatrixState
|
||||
.pangeaController.userController.profile.userSettings.cefrLevel,
|
||||
lemma: req.lemma,
|
||||
pos: req.partOfSpeech,
|
||||
count: count,
|
||||
),
|
||||
);
|
||||
eligible.vocab.shuffle();
|
||||
|
||||
final List<ConstructIdentifier> distractorConstructUses =
|
||||
eligible.vocab.take(count).toList();
|
||||
|
||||
final List<Future<LemmaInfoResponse>> futureDefs = [];
|
||||
for (final construct in distractorConstructUses) {
|
||||
futureDefs.add(
|
||||
LemmaInfoRepo.get(
|
||||
LemmaInfoRequest(
|
||||
lemma: construct.lemma,
|
||||
partOfSpeech: construct.category,
|
||||
lemmaLang: req.lemmaLang,
|
||||
userL1: req.userL1,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final Set<String> distractorDefs = {};
|
||||
for (final def in await Future.wait(futureDefs)) {
|
||||
distractorDefs.add(def.meaning);
|
||||
}
|
||||
|
||||
return distractorDefs.toList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@ import 'dart:developer';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.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/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
typedef MorphActivitySequence = Map<String, POSActivitySequence>;
|
||||
|
||||
|
|
@ -17,9 +15,9 @@ typedef POSActivitySequence = List<String>;
|
|||
|
||||
class MorphActivityGenerator {
|
||||
/// Generate a morphological activity for a given token and morphological feature
|
||||
Future<MessageActivityResponse> get(
|
||||
static MessageActivityResponse get(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
) {
|
||||
debugger(when: kDebugMode && req.targetTokens.length != 1);
|
||||
|
||||
debugger(when: kDebugMode && req.targetMorphFeature == null);
|
||||
|
|
@ -34,8 +32,8 @@ class MorphActivityGenerator {
|
|||
throw "No morph tag found for morph feature";
|
||||
}
|
||||
|
||||
final List<String> distractors =
|
||||
token.morphActivityDistractors(morphFeature, morphTag);
|
||||
final distractors = token.morphActivityDistractors(morphFeature, morphTag);
|
||||
distractors.add(morphTag);
|
||||
|
||||
debugger(when: kDebugMode && distractors.length < 3);
|
||||
|
||||
|
|
@ -46,18 +44,8 @@ class MorphActivityGenerator {
|
|||
activityType: ActivityTypeEnum.morphId,
|
||||
morphFeature: req.targetMorphFeature,
|
||||
multipleChoiceContent: MultipleChoiceActivity(
|
||||
question: MatrixState.pangeaController.matrixState.context.mounted
|
||||
? L10n.of(MatrixState.pangeaController.matrixState.context)
|
||||
.whatIsTheMorphTag(
|
||||
morphFeature.getDisplayCopy(
|
||||
MatrixState.pangeaController.matrixState.context,
|
||||
),
|
||||
token.text.content,
|
||||
)
|
||||
: morphFeature.name,
|
||||
choices: distractors + [morphTag],
|
||||
answers: [morphTag],
|
||||
spanDisplayDetails: null,
|
||||
choices: distractors,
|
||||
answers: {morphTag},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,62 +1,25 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/relevant_span_display_details.dart';
|
||||
|
||||
class MultipleChoiceActivity {
|
||||
final String question;
|
||||
|
||||
/// choices, including the correct answer
|
||||
final List<String> choices;
|
||||
final List<String> answers;
|
||||
final RelevantSpanDisplayDetails? spanDisplayDetails;
|
||||
final Set<String> choices;
|
||||
final Set<String> answers;
|
||||
|
||||
MultipleChoiceActivity({
|
||||
required this.question,
|
||||
required this.choices,
|
||||
required this.answers,
|
||||
required this.spanDisplayDetails,
|
||||
});
|
||||
|
||||
/// we've had some bugs where the index is not expected
|
||||
/// so we're going to check if the index or the value is correct
|
||||
/// and if not, we'll investigate
|
||||
bool isCorrect(String value, int index) {
|
||||
if (value != choices[index]) {
|
||||
debugger(when: kDebugMode);
|
||||
}
|
||||
return answers.contains(value) || correctAnswerIndices.contains(index);
|
||||
}
|
||||
Color choiceColor(String value) =>
|
||||
answers.contains(value) ? AppConfig.success : AppConfig.warning;
|
||||
|
||||
bool get isValidQuestion => choices.toSet().containsAll(answers);
|
||||
|
||||
List<int> get correctAnswerIndices {
|
||||
final List<int> indices = [];
|
||||
for (var i = 0; i < choices.length; i++) {
|
||||
if (answers.contains(choices[i])) {
|
||||
indices.add(i);
|
||||
}
|
||||
}
|
||||
return indices;
|
||||
}
|
||||
|
||||
int choiceIndex(String choice) => choices.indexOf(choice);
|
||||
|
||||
Color choiceColor(int index) => correctAnswerIndices.contains(index)
|
||||
? AppConfig.success
|
||||
: AppConfig.warning;
|
||||
bool isCorrect(String value) => answers.contains(value);
|
||||
|
||||
factory MultipleChoiceActivity.fromJson(Map<String, dynamic> json) {
|
||||
final spanDisplay = json['span_display_details'] != null &&
|
||||
json['span_display_details'] is Map
|
||||
? RelevantSpanDisplayDetails.fromJson(json['span_display_details'])
|
||||
: null;
|
||||
|
||||
final answerEntry = json['answer'] ?? json['correct_answer'] ?? "";
|
||||
List<String> answers = [];
|
||||
if (answerEntry is String) {
|
||||
|
|
@ -66,19 +29,15 @@ class MultipleChoiceActivity {
|
|||
}
|
||||
|
||||
return MultipleChoiceActivity(
|
||||
question: json['question'] as String,
|
||||
choices: (json['choices'] as List).map((e) => e as String).toList(),
|
||||
answers: answers,
|
||||
spanDisplayDetails: spanDisplay,
|
||||
choices: (json['choices'] as List).map((e) => e as String).toSet(),
|
||||
answers: answers.toSet(),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'question': question,
|
||||
'choices': choices,
|
||||
'answer': answers,
|
||||
'span_display_details': spanDisplayDetails?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -88,13 +47,12 @@ class MultipleChoiceActivity {
|
|||
if (identical(this, other)) return true;
|
||||
|
||||
return other is MultipleChoiceActivity &&
|
||||
other.question == question &&
|
||||
other.choices == choices &&
|
||||
const ListEquality().equals(other.answers.sorted(), answers.sorted());
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return question.hashCode ^ choices.hashCode ^ Object.hashAll(answers);
|
||||
return choices.hashCode ^ Object.hashAll(answers);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,21 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.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/multiple_choice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_choice.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_match.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_record.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/relevant_span_display_details.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class PracticeActivityModel {
|
||||
List<PangeaToken> targetTokens;
|
||||
final List<PangeaToken> targetTokens;
|
||||
final ActivityTypeEnum activityType;
|
||||
final MorphFeaturesEnum? morphFeature;
|
||||
|
||||
|
|
@ -57,13 +46,15 @@ class PracticeActivityModel {
|
|||
}
|
||||
}
|
||||
|
||||
bool get isComplete => practiceTarget.isComplete;
|
||||
PracticeTarget get practiceTarget => PracticeTarget(
|
||||
tokens: targetTokens,
|
||||
activityType: activityType,
|
||||
morphFeature: morphFeature,
|
||||
);
|
||||
|
||||
void onMultipleChoiceSelect(
|
||||
bool onMultipleChoiceSelect(
|
||||
PangeaToken token,
|
||||
PracticeChoice choice,
|
||||
PangeaMessageEvent? event,
|
||||
void Function() callback,
|
||||
) {
|
||||
if (multipleChoiceContent == null) {
|
||||
debugger(when: kDebugMode);
|
||||
|
|
@ -72,23 +63,21 @@ class PracticeActivityModel {
|
|||
s: StackTrace.current,
|
||||
data: toJson(),
|
||||
);
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// final ConstructIdentifier? cId = activityType == ActivityTypeEnum.morphId
|
||||
// ? morphFeature ?= null ? token.getMorphTag(morphFeature) : null
|
||||
// : choice.form.cId;
|
||||
|
||||
if (practiceTarget.record.hasTextResponse(choice.choiceContent) ||
|
||||
isComplete) {
|
||||
if (practiceTarget.isComplete ||
|
||||
practiceTarget.record.alreadyHasMatchResponse(
|
||||
choice.form.cId,
|
||||
choice.choiceContent,
|
||||
)) {
|
||||
// the user has already selected this choice
|
||||
// so we don't want to record it again
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
final bool isCorrect = multipleChoiceContent!.answers.any(
|
||||
(answer) => answer.toLowerCase() == choice.choiceContent.toLowerCase(),
|
||||
);
|
||||
final bool isCorrect =
|
||||
multipleChoiceContent!.isCorrect(choice.choiceContent);
|
||||
|
||||
// NOTE: the response is associated with the contructId of the choice, not the selected token
|
||||
// example: the user selects the word "cat" to match with the emoji 🐶
|
||||
|
|
@ -100,53 +89,21 @@ class PracticeActivityModel {
|
|||
score: isCorrect ? 1 : 0,
|
||||
);
|
||||
|
||||
// debugPrint(
|
||||
// "onMultipleChoiceSelect: ${choice.form} ${responseUseType(choice)}",
|
||||
// );
|
||||
|
||||
final constructUseType =
|
||||
practiceTarget.record.responses.last.useType(activityType);
|
||||
MatrixState.pangeaController.putAnalytics.setState(
|
||||
AnalyticsStream(
|
||||
eventId: event?.eventId,
|
||||
roomId: event?.room.id,
|
||||
constructs: [
|
||||
OneConstructUse(
|
||||
useType: constructUseType,
|
||||
lemma: choice.form.cId.lemma,
|
||||
constructType: choice.form.cId.type,
|
||||
metadata: ConstructUseMetaData(
|
||||
roomId: event?.room.id,
|
||||
timeStamp: DateTime.now(),
|
||||
eventId: event?.eventId,
|
||||
),
|
||||
category: choice.form.cId.category,
|
||||
form: choice.form.form,
|
||||
xp: constructUseType.pointValue,
|
||||
),
|
||||
],
|
||||
targetID: targetTokens.first.text.uniqueKey,
|
||||
),
|
||||
);
|
||||
|
||||
callback();
|
||||
return isCorrect;
|
||||
}
|
||||
|
||||
/// only set up for vocab constructs atm
|
||||
void onMatch(
|
||||
bool onMatch(
|
||||
PangeaToken token,
|
||||
PracticeChoice choice,
|
||||
PangeaMessageEvent? event,
|
||||
void Function() callback,
|
||||
) {
|
||||
// the user has already selected this choice
|
||||
// so we don't want to record it again
|
||||
if (practiceTarget.record.alreadyHasMatchResponse(
|
||||
if (practiceTarget.isComplete ||
|
||||
practiceTarget.record.alreadyHasMatchResponse(
|
||||
token.vocabConstructID,
|
||||
choice.choiceContent,
|
||||
) ||
|
||||
isComplete) {
|
||||
return;
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
bool isCorrect = false;
|
||||
|
|
@ -154,21 +111,13 @@ class PracticeActivityModel {
|
|||
isCorrect = multipleChoiceContent!.answers.any(
|
||||
(answer) => answer.toLowerCase() == choice.choiceContent.toLowerCase(),
|
||||
);
|
||||
} else if (matchContent != null) {
|
||||
} else {
|
||||
// we check to see if it's in the list of acceptable answers
|
||||
// rather than if the vocabForm is the same because an emoji
|
||||
// could be in multiple constructs so there could be multiple answers
|
||||
final answers = matchContent!.matchInfo[token.vocabForm];
|
||||
debugger(when: answers == null && kDebugMode);
|
||||
isCorrect = answers!.contains(choice.choiceContent);
|
||||
} else {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
m: "in onMatch with null matchContent and multipleChoiceContent",
|
||||
s: StackTrace.current,
|
||||
data: toJson(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// NOTE: the response is associated with the contructId of the selected token, not the choice
|
||||
|
|
@ -176,100 +125,12 @@ class PracticeActivityModel {
|
|||
// the response is associated with incorrect word "cat", not the word "dog"
|
||||
practiceTarget.record.addResponse(
|
||||
cId: token.vocabConstructID,
|
||||
text: choice.choiceContent,
|
||||
target: practiceTarget,
|
||||
text: choice.choiceContent,
|
||||
score: isCorrect ? 1 : 0,
|
||||
);
|
||||
|
||||
// we don't take off points for incorrect emoji matches
|
||||
if (ActivityTypeEnum.emoji != activityType || isCorrect) {
|
||||
final constructUseType =
|
||||
practiceTarget.record.responses.last.useType(activityType);
|
||||
MatrixState.pangeaController.putAnalytics.setState(
|
||||
AnalyticsStream(
|
||||
eventId: event?.eventId,
|
||||
roomId: event?.room.id,
|
||||
constructs: [
|
||||
OneConstructUse(
|
||||
useType: constructUseType,
|
||||
lemma: token.lemma.text,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
metadata: ConstructUseMetaData(
|
||||
roomId: event?.room.id,
|
||||
timeStamp: DateTime.now(),
|
||||
eventId: event?.eventId,
|
||||
),
|
||||
category: token.pos,
|
||||
// in the case of a wrong answer, the cId doesn't match the token
|
||||
form: token.text.content,
|
||||
xp: constructUseType.pointValue,
|
||||
),
|
||||
],
|
||||
targetID: "message-token-${token.text.uniqueKey}-${event?.eventId}",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isCorrect) {
|
||||
if (activityType == ActivityTypeEnum.emoji) {
|
||||
choice.form.cId
|
||||
.setEmojiWithXP(
|
||||
emoji: choice.choiceContent,
|
||||
isFromCorrectAnswer: true,
|
||||
eventId: event?.eventId,
|
||||
roomId: event?.room.id,
|
||||
)
|
||||
.then((value) {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
|
||||
if (activityType == ActivityTypeEnum.wordMeaning) {
|
||||
choice.form.cId
|
||||
.setUserLemmaInfo(UserSetLemmaInfo(meaning: choice.choiceContent))
|
||||
.then((value) {
|
||||
callback();
|
||||
});
|
||||
}
|
||||
}
|
||||
callback();
|
||||
}
|
||||
|
||||
PracticeRecord get record => practiceTarget.record;
|
||||
|
||||
PracticeTarget get practiceTarget => PracticeTarget(
|
||||
tokens: targetTokens,
|
||||
activityType: activityType,
|
||||
userL2: langCode,
|
||||
morphFeature: morphFeature,
|
||||
);
|
||||
|
||||
String get targetLemma => targetTokens.first.lemma.text;
|
||||
|
||||
String get partOfSpeech => targetTokens.first.pos;
|
||||
|
||||
String get targetWordForm => targetTokens.first.text.content;
|
||||
|
||||
/// we were setting the question copy on creation of the activity
|
||||
/// but, in order to localize the question using the same system
|
||||
/// as other copy, we should do it with context, when it is built
|
||||
/// some types are doing this now, others should be migrated
|
||||
String question(BuildContext context, MorphFeaturesEnum? morphFeature) {
|
||||
switch (activityType) {
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
return multipleChoiceContent?.question ?? "You can do it!";
|
||||
case ActivityTypeEnum.emoji:
|
||||
return L10n.of(context).pickAnEmoji(targetLemma, partOfSpeech);
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return L10n.of(context).whatIsMeaning(targetLemma, partOfSpeech);
|
||||
case ActivityTypeEnum.morphId:
|
||||
return L10n.of(context).whatIsTheMorphTag(
|
||||
morphFeature!.getDisplayCopy(context),
|
||||
targetWordForm,
|
||||
);
|
||||
}
|
||||
return isCorrect;
|
||||
}
|
||||
|
||||
factory PracticeActivityModel.fromJson(Map<String, dynamic> json) {
|
||||
|
|
@ -323,9 +184,6 @@ class PracticeActivityModel {
|
|||
);
|
||||
}
|
||||
|
||||
RelevantSpanDisplayDetails? get relevantSpanDisplayDetails =>
|
||||
multipleChoiceContent?.spanDisplayDetails;
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'lang_code': langCode,
|
||||
|
|
|
|||
|
|
@ -3,18 +3,15 @@ import 'dart:convert';
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:http/http.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/common/config/environment.dart';
|
||||
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
|
||||
import 'package:fluffychat/pangea/common/network/requests.dart';
|
||||
import 'package:fluffychat/pangea/common/network/urls.dart';
|
||||
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/emoji_activity_generator.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/lemma_activity_generator.dart';
|
||||
|
|
@ -23,80 +20,69 @@ import 'package:fluffychat/pangea/practice_activities/message_activity_request.d
|
|||
import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/word_focus_listening_generator.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
/// Represents an item in the completion cache.
|
||||
class _RequestCacheItem {
|
||||
final MessageActivityRequest req;
|
||||
final PracticeActivityModelResponse practiceActivity;
|
||||
final DateTime createdAt = DateTime.now();
|
||||
final PracticeActivityModel practiceActivity;
|
||||
final DateTime timestamp;
|
||||
|
||||
_RequestCacheItem({
|
||||
required this.req,
|
||||
required this.practiceActivity,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
bool get isExpired =>
|
||||
DateTime.now().difference(timestamp) > PracticeRepo._cacheDuration;
|
||||
|
||||
factory _RequestCacheItem.fromJson(Map<String, dynamic> json) {
|
||||
return _RequestCacheItem(
|
||||
practiceActivity:
|
||||
PracticeActivityModel.fromJson(json['practiceActivity']),
|
||||
timestamp: DateTime.parse(json['timestamp']),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'practiceActivity': practiceActivity.toJson(),
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Controller for handling activity completions.
|
||||
class PracticeRepo {
|
||||
static final Map<int, _RequestCacheItem> _cache = {};
|
||||
Timer? _cacheClearTimer;
|
||||
static final GetStorage _storage = GetStorage('practice_activity_cache');
|
||||
static const Duration _cacheDuration = Duration(minutes: 1);
|
||||
|
||||
late PangeaController _pangeaController;
|
||||
|
||||
final _morph = MorphActivityGenerator();
|
||||
final _emoji = EmojiActivityGenerator();
|
||||
final _lemma = LemmaActivityGenerator();
|
||||
final _wordFocusListening = WordFocusListeningGenerator();
|
||||
final _wordMeaning = LemmaMeaningActivityGenerator();
|
||||
|
||||
PracticeRepo() {
|
||||
_pangeaController = MatrixState.pangeaController;
|
||||
_initializeCacheClearing();
|
||||
}
|
||||
|
||||
void _initializeCacheClearing() {
|
||||
const duration = Duration(minutes: 10);
|
||||
_cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache());
|
||||
}
|
||||
|
||||
void _clearCache() {
|
||||
final now = DateTime.now();
|
||||
final keys = _cache.keys.toList();
|
||||
for (final key in keys) {
|
||||
final item = _cache[key]!;
|
||||
if (now.difference(item.createdAt) > const Duration(minutes: 10)) {
|
||||
_cache.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_cacheClearTimer?.cancel();
|
||||
}
|
||||
|
||||
Future<PracticeActivityEvent?> _sendAndPackageEvent(
|
||||
PracticeActivityModel model,
|
||||
PangeaMessageEvent pangeaMessageEvent,
|
||||
/// [event] is optional and used for saving the activity event to Matrix
|
||||
static Future<Result<PracticeActivityModel>> getPracticeActivity(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
final Event? activityEvent = await pangeaMessageEvent.room.sendPangeaEvent(
|
||||
content: model.toJson(),
|
||||
parentEventId: pangeaMessageEvent.eventId,
|
||||
type: PangeaEventTypes.pangeaActivity,
|
||||
);
|
||||
final cached = _getCached(req);
|
||||
if (cached != null) return Result.value(cached);
|
||||
|
||||
if (activityEvent == null) {
|
||||
return null;
|
||||
try {
|
||||
final MessageActivityResponse res = await _routePracticeActivity(
|
||||
accessToken: MatrixState.pangeaController.userController.accessToken,
|
||||
req: req,
|
||||
);
|
||||
|
||||
_setCached(req, res);
|
||||
return Result.value(res.activity);
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {
|
||||
'message': 'Error fetching practice activity',
|
||||
'request': req.toJson(),
|
||||
},
|
||||
);
|
||||
return Result.error(e, s);
|
||||
}
|
||||
|
||||
return PracticeActivityEvent(
|
||||
event: activityEvent,
|
||||
timeline: pangeaMessageEvent.timeline,
|
||||
);
|
||||
}
|
||||
|
||||
Future<MessageActivityResponse> _fetchFromServer({
|
||||
static Future<MessageActivityResponse> _fetch({
|
||||
required String accessToken,
|
||||
required MessageActivityRequest requestModel,
|
||||
}) async {
|
||||
|
|
@ -109,96 +95,76 @@ class PracticeRepo {
|
|||
body: requestModel.toJson(),
|
||||
);
|
||||
|
||||
if (res.statusCode == 200) {
|
||||
final Map<String, dynamic> json = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
|
||||
final response = MessageActivityResponse.fromJson(json);
|
||||
|
||||
return response;
|
||||
} else {
|
||||
debugger(when: kDebugMode);
|
||||
throw Exception('Failed to create activity');
|
||||
if (res.statusCode != 200) {
|
||||
throw Exception('Failed to fetch activity');
|
||||
}
|
||||
|
||||
final Map<String, dynamic> json = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
return MessageActivityResponse.fromJson(json);
|
||||
}
|
||||
|
||||
Future<MessageActivityResponse> _routePracticeActivity({
|
||||
static Future<MessageActivityResponse> _routePracticeActivity({
|
||||
required String accessToken,
|
||||
required MessageActivityRequest req,
|
||||
required BuildContext context,
|
||||
}) async {
|
||||
// some activities we'll get from the server and others we'll generate locally
|
||||
switch (req.targetType) {
|
||||
case ActivityTypeEnum.emoji:
|
||||
return _emoji.get(req);
|
||||
return EmojiActivityGenerator.get(req);
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
return _lemma.get(req, context);
|
||||
return LemmaActivityGenerator.get(req);
|
||||
case ActivityTypeEnum.morphId:
|
||||
return _morph.get(req);
|
||||
return MorphActivityGenerator.get(req);
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
debugger(when: kDebugMode);
|
||||
return _wordMeaning.get(req);
|
||||
return LemmaMeaningActivityGenerator.get(req);
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return _wordFocusListening.get(req);
|
||||
return WordFocusListeningGenerator.get(req);
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return _fetchFromServer(
|
||||
return _fetch(
|
||||
accessToken: accessToken,
|
||||
requestModel: req,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// [event] is optional and used for saving the activity event to Matrix
|
||||
Future<PracticeActivityModelResponse> getPracticeActivity(
|
||||
static PracticeActivityModel? _getCached(
|
||||
MessageActivityRequest req,
|
||||
PangeaMessageEvent? event,
|
||||
BuildContext context,
|
||||
) async {
|
||||
final int cacheKey = req.hashCode;
|
||||
|
||||
if (_cache.containsKey(cacheKey)) {
|
||||
return _cache[cacheKey]!.practiceActivity;
|
||||
) {
|
||||
final keys = List.from(_storage.getKeys());
|
||||
for (final k in keys) {
|
||||
try {
|
||||
final item = _RequestCacheItem.fromJson(_storage.read(k));
|
||||
if (item.isExpired) {
|
||||
_storage.remove(k);
|
||||
}
|
||||
} catch (e) {
|
||||
_storage.remove(k);
|
||||
}
|
||||
}
|
||||
|
||||
final MessageActivityResponse res = await _routePracticeActivity(
|
||||
accessToken: _pangeaController.userController.accessToken,
|
||||
req: req,
|
||||
context: context,
|
||||
);
|
||||
|
||||
// this improves the UI by generally packing wrapped choices more tightly
|
||||
res.activity.multipleChoiceContent?.choices
|
||||
.sort((a, b) => a.length.compareTo(b.length));
|
||||
|
||||
// TODO resolve some wierdness here whereby the activity can be null but then... it's not
|
||||
final eventCompleter = Completer<PracticeActivityEvent?>();
|
||||
|
||||
if (event != null) {
|
||||
_sendAndPackageEvent(res.activity, event).then((event) {
|
||||
eventCompleter.complete(event);
|
||||
});
|
||||
try {
|
||||
final entry = _RequestCacheItem.fromJson(
|
||||
_storage.read(req.hashCode.toString()),
|
||||
);
|
||||
return entry.practiceActivity;
|
||||
} catch (e) {
|
||||
_storage.remove(req.hashCode.toString());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
final responseModel = PracticeActivityModelResponse(
|
||||
activity: res.activity,
|
||||
eventCompleter: eventCompleter,
|
||||
static void _setCached(
|
||||
MessageActivityRequest req,
|
||||
MessageActivityResponse res,
|
||||
) {
|
||||
_storage.write(
|
||||
req.hashCode.toString(),
|
||||
_RequestCacheItem(
|
||||
practiceActivity: res.activity,
|
||||
timestamp: DateTime.now(),
|
||||
).toJson(),
|
||||
);
|
||||
|
||||
_cache[cacheKey] = _RequestCacheItem(
|
||||
req: req,
|
||||
practiceActivity: responseModel,
|
||||
);
|
||||
|
||||
return responseModel;
|
||||
}
|
||||
}
|
||||
|
||||
class PracticeActivityModelResponse {
|
||||
final PracticeActivityModel? activity;
|
||||
final Completer<PracticeActivityEvent?> eventCompleter;
|
||||
|
||||
PracticeActivityModelResponse({
|
||||
required this.activity,
|
||||
required this.eventCompleter,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,10 +54,6 @@ class PracticeMatchActivity {
|
|||
);
|
||||
}
|
||||
|
||||
bool isCorrect(ConstructForm form, String value) {
|
||||
return matchInfo[form]!.contains(value);
|
||||
}
|
||||
|
||||
factory PracticeMatchActivity.fromJson(Map<String, dynamic> json) {
|
||||
final Map<ConstructForm, List<String>> matchInfo = {};
|
||||
for (final constructJson in json['match_info']) {
|
||||
|
|
|
|||
|
|
@ -7,25 +7,19 @@ import 'dart:developer';
|
|||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
|
||||
class PracticeRecord {
|
||||
late DateTime createdAt;
|
||||
late List<ActivityRecordResponse> responses;
|
||||
|
||||
PracticeRecord({
|
||||
List<ActivityRecordResponse>? responses,
|
||||
DateTime? timestamp,
|
||||
}) {
|
||||
createdAt = timestamp ?? DateTime.now();
|
||||
if (responses == null) {
|
||||
this.responses = List<ActivityRecordResponse>.empty(growable: true);
|
||||
} else {
|
||||
|
|
@ -51,7 +45,6 @@ class PracticeRecord {
|
|||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'responses': responses.map((e) => e.toJson()).toList(),
|
||||
'createdAt': createdAt.toIso8601String(),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -68,87 +61,42 @@ class PracticeRecord {
|
|||
return responses[responses.length - 1];
|
||||
}
|
||||
|
||||
bool hasTextResponse(String text) {
|
||||
return responses.any((element) => element.text == text);
|
||||
}
|
||||
|
||||
bool alreadyHasMatchResponse(
|
||||
ConstructIdentifier cId,
|
||||
String text,
|
||||
) {
|
||||
return responses.any(
|
||||
(element) => element.cId == cId && element.text == text,
|
||||
);
|
||||
}
|
||||
) =>
|
||||
responses.any(
|
||||
(element) => element.cId == cId && element.text == text,
|
||||
);
|
||||
|
||||
/// [target] needed for saving the record, little funky
|
||||
/// [cId] identifies the construct in the case of match activities which have multiple
|
||||
/// [text] is the user's response
|
||||
/// [audioBytes] is the user's audio response
|
||||
/// [imageBytes] is the user's image response
|
||||
/// [score] > 0 means correct, otherwise is incorrect
|
||||
void addResponse({
|
||||
required ConstructIdentifier cId,
|
||||
required PracticeTarget target,
|
||||
String? text,
|
||||
Uint8List? audioBytes,
|
||||
Uint8List? imageBytes,
|
||||
required String text,
|
||||
required double score,
|
||||
}) {
|
||||
try {
|
||||
if (text == null && audioBytes == null && imageBytes == null) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
m: "No response data provided",
|
||||
data: {
|
||||
'cId': cId.toJson(),
|
||||
'text': text,
|
||||
'audioBytes': audioBytes,
|
||||
'imageBytes': imageBytes,
|
||||
'score': score,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
responses.add(
|
||||
ActivityRecordResponse(
|
||||
cId: cId,
|
||||
text: text,
|
||||
audioBytes: audioBytes,
|
||||
imageBytes: imageBytes,
|
||||
timestamp: DateTime.now(),
|
||||
score: score,
|
||||
),
|
||||
);
|
||||
debugPrint("responses: ${responses.map((r) => r.toJson())}");
|
||||
responses.add(
|
||||
ActivityRecordResponse(
|
||||
cId: cId,
|
||||
text: text,
|
||||
audioBytes: null,
|
||||
imageBytes: null,
|
||||
timestamp: DateTime.now(),
|
||||
score: score,
|
||||
),
|
||||
);
|
||||
|
||||
PracticeRecordRepo.save(target, this);
|
||||
try {
|
||||
PracticeRecordRepo.set(target, this);
|
||||
} catch (e) {
|
||||
debugger(when: kDebugMode);
|
||||
}
|
||||
}
|
||||
|
||||
void clearResponses() {
|
||||
responses.clear();
|
||||
}
|
||||
|
||||
/// Returns a list of [OneConstructUse] objects representing the uses of the practice activity.
|
||||
///
|
||||
/// The [practiceActivity] parameter is the parent event, representing the activity itself.
|
||||
/// The [metadata] parameter is the metadata for the construct use, used if the record event isn't available.
|
||||
///
|
||||
/// The method iterates over the [responses] to get [OneConstructUse] objects for each
|
||||
List<OneConstructUse> usesForAllResponses(
|
||||
PracticeActivityModel practiceActivity,
|
||||
ConstructUseMetaData metadata,
|
||||
) =>
|
||||
responses
|
||||
.toSet()
|
||||
.expand(
|
||||
(response) => response.toUses(practiceActivity, metadata),
|
||||
)
|
||||
.toList();
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
|
@ -193,110 +141,6 @@ class ActivityRecordResponse {
|
|||
ConstructUseTypeEnum useType(ActivityTypeEnum aType) =>
|
||||
isCorrect ? aType.correctUse : aType.incorrectUse;
|
||||
|
||||
// for each target construct create a OneConstructUse object
|
||||
List<OneConstructUse> toUses(
|
||||
PracticeActivityModel practiceActivity,
|
||||
ConstructUseMetaData metadata,
|
||||
) {
|
||||
// if the emoji is already set, don't give points
|
||||
// IMPORTANT: This assumes that scoring is happening before saving of the user's emoji choice.
|
||||
if (practiceActivity.activityType == ActivityTypeEnum.emoji &&
|
||||
practiceActivity.targetTokens.first.getEmoji().isNotEmpty) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (practiceActivity.targetTokens.isEmpty) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
m: "null targetTokens in practice activity",
|
||||
data: practiceActivity.toJson(),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
|
||||
switch (practiceActivity.activityType) {
|
||||
case ActivityTypeEnum.emoji:
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.lemmaId:
|
||||
final token = practiceActivity.targetTokens.first;
|
||||
final constructUseType = useType(practiceActivity.activityType);
|
||||
return [
|
||||
OneConstructUse(
|
||||
lemma: token.lemma.text,
|
||||
form: token.text.content,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
useType: constructUseType,
|
||||
metadata: metadata,
|
||||
category: token.pos,
|
||||
xp: constructUseType.pointValue,
|
||||
),
|
||||
];
|
||||
case ActivityTypeEnum.messageMeaning:
|
||||
final constructUseType = useType(practiceActivity.activityType);
|
||||
return practiceActivity.targetTokens
|
||||
.expand(
|
||||
(t) => t.allUses(
|
||||
constructUseType,
|
||||
metadata,
|
||||
constructUseType.pointValue,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
final constructUseType = useType(practiceActivity.activityType);
|
||||
return practiceActivity.targetTokens
|
||||
.map(
|
||||
(token) => OneConstructUse(
|
||||
lemma: token.lemma.text,
|
||||
form: token.text.content,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
useType: constructUseType,
|
||||
metadata: metadata,
|
||||
category: token.pos,
|
||||
xp: constructUseType.pointValue,
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
case ActivityTypeEnum.morphId:
|
||||
if (practiceActivity.morphFeature == null) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
m: "null morphFeature in morph activity",
|
||||
data: practiceActivity.toJson(),
|
||||
);
|
||||
return [];
|
||||
}
|
||||
return practiceActivity.targetTokens
|
||||
.map(
|
||||
(t) {
|
||||
final tag = t.getMorphTag(practiceActivity.morphFeature!);
|
||||
if (tag == null) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
m: "null tag in morph activity",
|
||||
data: practiceActivity.toJson(),
|
||||
);
|
||||
return null;
|
||||
}
|
||||
final constructUseType = useType(practiceActivity.activityType);
|
||||
return OneConstructUse(
|
||||
lemma: tag,
|
||||
form: practiceActivity.targetTokens.first.text.content,
|
||||
constructType: ConstructTypeEnum.morph,
|
||||
useType: constructUseType,
|
||||
metadata: metadata,
|
||||
category: practiceActivity.morphFeature!,
|
||||
xp: constructUseType.pointValue,
|
||||
);
|
||||
},
|
||||
)
|
||||
.where((c) => c != null)
|
||||
.cast<OneConstructUse>()
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
|
||||
factory ActivityRecordResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ActivityRecordResponse(
|
||||
cId: ConstructIdentifier.fromJson(json['cId'] as Map<String, dynamic>),
|
||||
|
|
|
|||
|
|
@ -1,78 +1,61 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_record.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
|
||||
class _PracticeRecordCacheEntry {
|
||||
final PracticeRecord record;
|
||||
final DateTime timestamp;
|
||||
|
||||
_PracticeRecordCacheEntry({
|
||||
required this.record,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
bool get isExpired => DateTime.now().difference(timestamp).inMinutes > 15;
|
||||
}
|
||||
|
||||
/// Controller for handling activity completions.
|
||||
class PracticeRecordRepo {
|
||||
static final GetStorage _storage = GetStorage('practice_record_cache');
|
||||
static final Map<String, PracticeRecord> _memoryCache = {};
|
||||
static const int _maxMemoryCacheSize = 50;
|
||||
|
||||
void dispose() {
|
||||
_storage.erase();
|
||||
_memoryCache.clear();
|
||||
}
|
||||
|
||||
static void save(
|
||||
PracticeTarget selection,
|
||||
PracticeRecord entry,
|
||||
) {
|
||||
_storage.write(selection.storageKey, entry.toJson());
|
||||
_memoryCache[selection.storageKey] = entry;
|
||||
}
|
||||
|
||||
static void clean() {
|
||||
final keys = _storage.getKeys();
|
||||
if (keys.length > 300) {
|
||||
final entries = keys
|
||||
.map((key) {
|
||||
final entry = PracticeRecord.fromJson(_storage.read(key));
|
||||
return MapEntry(key, entry);
|
||||
})
|
||||
.cast<MapEntry<String, PracticeRecord>>()
|
||||
.toList()
|
||||
..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
|
||||
for (var i = 0; i < 5; i++) {
|
||||
_storage.remove(entries[i].key);
|
||||
}
|
||||
}
|
||||
if (_memoryCache.length > _maxMemoryCacheSize) {
|
||||
_memoryCache.remove(_memoryCache.keys.first);
|
||||
}
|
||||
}
|
||||
static final Map<String, _PracticeRecordCacheEntry> _cache = {};
|
||||
|
||||
static PracticeRecord get(
|
||||
PracticeTarget target,
|
||||
) {
|
||||
final String key = target.storageKey;
|
||||
if (_memoryCache.containsKey(key)) {
|
||||
return _memoryCache[key]!;
|
||||
}
|
||||
final cached = _getCached(target);
|
||||
if (cached != null) return cached;
|
||||
|
||||
final entryJson = _storage.read(key);
|
||||
if (entryJson != null) {
|
||||
final entry = PracticeRecord.fromJson(entryJson);
|
||||
if (DateTime.now().difference(entry.createdAt).inDays > 1) {
|
||||
debugPrint('removing old entry ${entry.createdAt}');
|
||||
_storage.remove(key);
|
||||
} else {
|
||||
_memoryCache[key] = entry;
|
||||
return entry;
|
||||
final entry = PracticeRecord();
|
||||
_setCached(target, entry);
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
static void set(
|
||||
PracticeTarget selection,
|
||||
PracticeRecord entry,
|
||||
) =>
|
||||
_setCached(selection, entry);
|
||||
|
||||
static PracticeRecord? _getCached(
|
||||
PracticeTarget target,
|
||||
) {
|
||||
final keys = List.from(_cache.keys);
|
||||
for (final k in keys) {
|
||||
final item = _cache[k]!;
|
||||
if (item.isExpired) {
|
||||
_cache.remove(k);
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint('creating new practice record for $key');
|
||||
final newEntry = PracticeRecord();
|
||||
return _cache[target.storageKey]?.record;
|
||||
}
|
||||
|
||||
_storage.write(key, newEntry.toJson());
|
||||
_memoryCache[key] = newEntry;
|
||||
|
||||
clean();
|
||||
|
||||
return newEntry;
|
||||
static void _setCached(
|
||||
PracticeTarget target,
|
||||
PracticeRecord entry,
|
||||
) {
|
||||
_cache[target.storageKey] = _PracticeRecordCacheEntry(
|
||||
record: entry,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,53 +1,31 @@
|
|||
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();
|
||||
final Map<ActivityTypeEnum, List<PracticeTarget>> _activityQueue;
|
||||
static const int maxQueueLength = 5;
|
||||
|
||||
late final List<PangeaToken> _tokens;
|
||||
PracticeSelection(this._activityQueue);
|
||||
|
||||
final String langCode;
|
||||
List<PracticeTarget> activities(ActivityTypeEnum a) =>
|
||||
_activityQueue[a] ?? [];
|
||||
|
||||
final Map<ActivityTypeEnum, List<PracticeTarget>> _activityQueue = {};
|
||||
PracticeTarget? getTarget(ActivityTypeEnum type) =>
|
||||
activities(type).firstOrNull;
|
||||
|
||||
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];
|
||||
PracticeTarget? getMorphTarget(
|
||||
PangeaToken t,
|
||||
MorphFeaturesEnum morph,
|
||||
) =>
|
||||
activities(ActivityTypeEnum.morphId).firstWhereOrNull(
|
||||
(entry) => entry.tokens.contains(t) && entry.morphFeature == morph,
|
||||
);
|
||||
|
||||
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(),
|
||||
|
|
@ -58,292 +36,12 @@ class PracticeSelection {
|
|||
|
||||
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(),
|
||||
),
|
||||
(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 [];
|
||||
}
|
||||
|
||||
//remove duplicates
|
||||
final seenTexts = <String>{};
|
||||
final seemLemmas = <String>{};
|
||||
tokens.retainWhere(
|
||||
(token) =>
|
||||
seenTexts.add(token.text.content.toLowerCase()) &&
|
||||
seemLemmas.add(token.lemma.text.toLowerCase()),
|
||||
);
|
||||
|
||||
if (tokens.length > 8) {
|
||||
// Remove the last third (floored) of tokens, only greater than 8 items so at least 5 remain
|
||||
final int removeCount = (tokens.length / 3).floor();
|
||||
final int keepCount = tokens.length - removeCount;
|
||||
tokens.removeRange(keepCount, tokens.length);
|
||||
}
|
||||
|
||||
//shuffle leftover list so if there are enough, each activity gets different tokens
|
||||
tokens.shuffle();
|
||||
|
||||
final List<PangeaToken> activityTokens = [];
|
||||
for (final t in tokens) {
|
||||
if (activityTokens.length >= _maxQueueLength) {
|
||||
break;
|
||||
}
|
||||
activityTokens.add(t);
|
||||
}
|
||||
|
||||
return [
|
||||
PracticeTarget(
|
||||
activityType: activityType,
|
||||
tokens: activityTokens,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +1,211 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.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.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class _PracticeSelectionCacheEntry {
|
||||
final PracticeSelection selection;
|
||||
final DateTime timestamp;
|
||||
|
||||
_PracticeSelectionCacheEntry({
|
||||
required this.selection,
|
||||
required this.timestamp,
|
||||
});
|
||||
|
||||
bool get isExpired => DateTime.now().difference(timestamp).inDays > 1;
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
'selection': selection.toJson(),
|
||||
'timestamp': timestamp.toIso8601String(),
|
||||
};
|
||||
|
||||
factory _PracticeSelectionCacheEntry.fromJson(Map<String, dynamic> json) {
|
||||
return _PracticeSelectionCacheEntry(
|
||||
selection: PracticeSelection.fromJson(json['selection']),
|
||||
timestamp: DateTime.parse(json['timestamp']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PracticeSelectionRepo {
|
||||
static final GetStorage _storage = GetStorage('practice_selection_cache');
|
||||
static final Map<String, PracticeSelection> _memoryCache = {};
|
||||
static const int _maxMemoryCacheSize = 50;
|
||||
|
||||
void dispose() {
|
||||
_storage.erase();
|
||||
_memoryCache.clear();
|
||||
}
|
||||
|
||||
static void save(PracticeSelection entry) {
|
||||
final key = _key(entry.tokens);
|
||||
_storage.write(key, entry.toJson());
|
||||
_memoryCache[key] = entry;
|
||||
}
|
||||
|
||||
static MapEntry<String, PracticeSelection>? _parsePracticeSelection(
|
||||
String key,
|
||||
) {
|
||||
if (!_storage.hasData(key)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
final entry = PracticeSelection.fromJson(_storage.read(key));
|
||||
return MapEntry(key, entry);
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(
|
||||
m: 'Failed to parse PracticeSelection from JSON',
|
||||
e: e,
|
||||
s: s,
|
||||
data: {
|
||||
'key': key,
|
||||
'json': _storage.read(key),
|
||||
},
|
||||
);
|
||||
_storage.remove(key);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void clean() {
|
||||
final keys = _storage.getKeys();
|
||||
if (keys.length > 300) {
|
||||
final entries = keys
|
||||
.map((key) => _parsePracticeSelection(key))
|
||||
.where((entry) => entry != null)
|
||||
.cast<MapEntry<String, PracticeSelection>>()
|
||||
.toList()
|
||||
..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
|
||||
for (var i = 0; i < 5 && i < entries.length; i++) {
|
||||
_storage.remove(entries[i].key);
|
||||
}
|
||||
}
|
||||
if (_memoryCache.length > _maxMemoryCacheSize) {
|
||||
_memoryCache.remove(_memoryCache.keys.first);
|
||||
}
|
||||
}
|
||||
|
||||
static String _key(List<PangeaToken> tokens) =>
|
||||
tokens.map((t) => t.text.content).join(' ');
|
||||
|
||||
static PracticeSelection? get(
|
||||
String eventId,
|
||||
String messageLanguage,
|
||||
List<PangeaToken> tokens,
|
||||
) {
|
||||
final userL2 = MatrixState.pangeaController.languageController.userL2;
|
||||
final String key = _key(tokens);
|
||||
if (_memoryCache.containsKey(key)) {
|
||||
final entry = _memoryCache[key];
|
||||
return entry?.langCode.split("-").first == userL2?.langCodeShort
|
||||
? entry
|
||||
: null;
|
||||
if (userL2?.langCodeShort != messageLanguage.split("-").first) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final stored = _parsePracticeSelection(key);
|
||||
if (stored != null) {
|
||||
final entry = stored.value;
|
||||
if (DateTime.now().difference(entry.createdAt).inDays > 1) {
|
||||
debugPrint('removing old entry ${entry.createdAt}');
|
||||
final cached = _getCached(eventId);
|
||||
if (cached != null) return cached;
|
||||
|
||||
final newEntry = _fetch(
|
||||
tokens: tokens,
|
||||
langCode: messageLanguage,
|
||||
);
|
||||
|
||||
_setCached(eventId, newEntry);
|
||||
return newEntry;
|
||||
}
|
||||
|
||||
static PracticeSelection _fetch({
|
||||
required List<PangeaToken> tokens,
|
||||
required String langCode,
|
||||
}) {
|
||||
if (langCode.split("-")[0] !=
|
||||
MatrixState.pangeaController.languageController.userL2?.langCodeShort) {
|
||||
return PracticeSelection({});
|
||||
}
|
||||
|
||||
final eligibleTokens = tokens.where((t) => t.lemma.saveVocab).toList();
|
||||
if (eligibleTokens.isEmpty) {
|
||||
return PracticeSelection({});
|
||||
}
|
||||
final queue = _fillActivityQueue(eligibleTokens);
|
||||
final selection = PracticeSelection(queue);
|
||||
return selection;
|
||||
}
|
||||
|
||||
static PracticeSelection? _getCached(
|
||||
String eventId,
|
||||
) {
|
||||
for (final String key in _storage.getKeys()) {
|
||||
try {
|
||||
final cacheEntry = _PracticeSelectionCacheEntry.fromJson(
|
||||
_storage.read(key),
|
||||
);
|
||||
if (cacheEntry.isExpired) {
|
||||
_storage.remove(key);
|
||||
}
|
||||
} catch (e) {
|
||||
_storage.remove(key);
|
||||
} else {
|
||||
_memoryCache[key] = entry;
|
||||
return entry.langCode.split("-").first == userL2?.langCodeShort
|
||||
? entry
|
||||
: null;
|
||||
}
|
||||
}
|
||||
|
||||
final newEntry = PracticeSelection(
|
||||
langCode: messageLanguage,
|
||||
tokens: tokens,
|
||||
final entry = _storage.read(eventId);
|
||||
if (entry == null) return null;
|
||||
|
||||
try {
|
||||
return _PracticeSelectionCacheEntry.fromJson(
|
||||
_storage.read(eventId),
|
||||
).selection;
|
||||
} catch (e) {
|
||||
_storage.remove(eventId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static void _setCached(
|
||||
String eventId,
|
||||
PracticeSelection entry,
|
||||
) {
|
||||
final cachedEntry = _PracticeSelectionCacheEntry(
|
||||
selection: entry,
|
||||
timestamp: DateTime.now(),
|
||||
);
|
||||
_storage.write(eventId, cachedEntry.toJson());
|
||||
}
|
||||
|
||||
static Map<ActivityTypeEnum, List<PracticeTarget>> _fillActivityQueue(
|
||||
List<PangeaToken> tokens,
|
||||
) {
|
||||
final queue = <ActivityTypeEnum, List<PracticeTarget>>{};
|
||||
for (final type in ActivityTypeEnum.practiceTypes) {
|
||||
queue[type] = _buildActivity(type, tokens);
|
||||
}
|
||||
return queue;
|
||||
}
|
||||
|
||||
static int _sortTokens(
|
||||
PangeaToken a,
|
||||
PangeaToken b,
|
||||
ActivityTypeEnum activityType,
|
||||
) {
|
||||
final bScore = b.activityPriorityScore(activityType, null);
|
||||
final aScore = a.activityPriorityScore(activityType, null);
|
||||
return bScore.compareTo(aScore);
|
||||
}
|
||||
|
||||
static int _sortMorphTargets(PracticeTarget a, PracticeTarget b) {
|
||||
final bScore = b.tokens.first.activityPriorityScore(
|
||||
ActivityTypeEnum.morphId,
|
||||
b.morphFeature!,
|
||||
);
|
||||
|
||||
_storage.write(key, newEntry.toJson());
|
||||
_memoryCache[key] = newEntry;
|
||||
final aScore = a.tokens.first.activityPriorityScore(
|
||||
ActivityTypeEnum.morphId,
|
||||
a.morphFeature!,
|
||||
);
|
||||
|
||||
clean();
|
||||
return bScore.compareTo(aScore);
|
||||
}
|
||||
|
||||
return newEntry.langCode.split("-").first == userL2?.langCodeShort
|
||||
? newEntry
|
||||
: null;
|
||||
static List<PracticeTarget> _tokenToMorphTargets(PangeaToken t) {
|
||||
return t.morphsBasicallyEligibleForPracticeByPriority
|
||||
.map(
|
||||
(m) => PracticeTarget(
|
||||
tokens: [t],
|
||||
activityType: ActivityTypeEnum.morphId,
|
||||
morphFeature: MorphFeaturesEnumExtension.fromString(m.category),
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
static List<PracticeTarget> _buildActivity(
|
||||
ActivityTypeEnum activityType,
|
||||
List<PangeaToken> tokens,
|
||||
) {
|
||||
if (activityType == ActivityTypeEnum.morphId) {
|
||||
return _buildMorphActivity(tokens);
|
||||
}
|
||||
|
||||
List<PangeaToken> practiceTokens = List<PangeaToken>.from(tokens);
|
||||
final seenTexts = <String>{};
|
||||
final seenLemmas = <String>{};
|
||||
practiceTokens.retainWhere(
|
||||
(token) =>
|
||||
token.eligibleForPractice(activityType) &&
|
||||
seenTexts.add(token.text.content.toLowerCase()) &&
|
||||
seenLemmas.add(token.lemma.text.toLowerCase()),
|
||||
);
|
||||
|
||||
if (practiceTokens.length < activityType.minTokensForMatchActivity) {
|
||||
return [];
|
||||
}
|
||||
|
||||
practiceTokens.sort((a, b) => _sortTokens(a, b, activityType));
|
||||
practiceTokens = practiceTokens.take(8).toList();
|
||||
practiceTokens.shuffle();
|
||||
|
||||
return [
|
||||
PracticeTarget(
|
||||
activityType: activityType,
|
||||
tokens: practiceTokens.take(PracticeSelection.maxQueueLength).toList(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
static List<PracticeTarget> _buildMorphActivity(List<PangeaToken> tokens) {
|
||||
final List<PangeaToken> practiceTokens = List<PangeaToken>.from(tokens);
|
||||
final candidates = practiceTokens.expand(_tokenToMorphTargets).toList();
|
||||
candidates.sort(_sortMorphTargets);
|
||||
|
||||
final seenTexts = <String>{};
|
||||
final seenLemmas = <String>{};
|
||||
candidates.retainWhere(
|
||||
(target) =>
|
||||
seenTexts.add(target.tokens.first.text.content.toLowerCase()) &&
|
||||
seenLemmas.add(target.tokens.first.lemma.text.toLowerCase()),
|
||||
);
|
||||
return candidates.take(PracticeSelection.maxQueueLength).toList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,9 @@ class PracticeTarget {
|
|||
/// this is only defined for morphId activities
|
||||
final MorphFeaturesEnum? morphFeature;
|
||||
|
||||
final String userL2;
|
||||
|
||||
PracticeTarget({
|
||||
required this.tokens,
|
||||
required this.activityType,
|
||||
required this.userL2,
|
||||
this.morphFeature,
|
||||
}) {
|
||||
if (ActivityTypeEnum.morphId == activityType && morphFeature == null) {
|
||||
|
|
@ -50,16 +47,12 @@ class PracticeTarget {
|
|||
return other is PracticeTarget &&
|
||||
listEquals(other.tokens, tokens) &&
|
||||
other.activityType == activityType &&
|
||||
other.morphFeature == morphFeature &&
|
||||
other.userL2 == userL2;
|
||||
other.morphFeature == morphFeature;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
tokens.hashCode ^
|
||||
activityType.hashCode ^
|
||||
morphFeature.hashCode ^
|
||||
userL2.hashCode;
|
||||
tokens.hashCode ^ activityType.hashCode ^ morphFeature.hashCode;
|
||||
|
||||
static PracticeTarget fromJson(Map<String, dynamic> json) {
|
||||
final type = ActivityTypeEnum.values.firstWhereOrNull(
|
||||
|
|
@ -78,7 +71,6 @@ class PracticeTarget {
|
|||
morphFeature: json['morphFeature'] == null
|
||||
? null
|
||||
: MorphFeaturesEnumExtension.fromString(json['morphFeature']),
|
||||
userL2: json['userL2'],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -87,7 +79,6 @@ class PracticeTarget {
|
|||
'tokens': tokens.map((e) => e.toJson()).toList(),
|
||||
'activityType': activityType.name,
|
||||
'morphFeature': morphFeature?.name,
|
||||
'userL2': userL2,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_display_instructions_enum.dart';
|
||||
|
||||
/// For those activities with a relevant span, this class will hold the details
|
||||
/// of the span and how it should be displayed
|
||||
/// e.g. hide the span for conjugation activities
|
||||
class RelevantSpanDisplayDetails {
|
||||
final int offset;
|
||||
final int length;
|
||||
final ActivityDisplayInstructionsEnum displayInstructions;
|
||||
|
||||
RelevantSpanDisplayDetails({
|
||||
required this.offset,
|
||||
required this.length,
|
||||
required this.displayInstructions,
|
||||
});
|
||||
|
||||
factory RelevantSpanDisplayDetails.fromJson(Map<String, dynamic> json) {
|
||||
final ActivityDisplayInstructionsEnum? display =
|
||||
ActivityDisplayInstructionsEnum.values.firstWhereOrNull(
|
||||
(e) => e.string == json['display_instructions'],
|
||||
);
|
||||
if (display == null) {
|
||||
debugger(when: kDebugMode);
|
||||
}
|
||||
return RelevantSpanDisplayDetails(
|
||||
offset: json['offset'] as int,
|
||||
length: json['length'] as int,
|
||||
displayInstructions: display ?? ActivityDisplayInstructionsEnum.nothing,
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'offset': offset,
|
||||
'length': length,
|
||||
'display_instructions': displayInstructions.string,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is RelevantSpanDisplayDetails &&
|
||||
other.offset == offset &&
|
||||
other.length == length &&
|
||||
other.displayInstructions == displayInstructions;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return offset.hashCode ^ length.hashCode ^ displayInstructions.hashCode;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +1,19 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_form.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_match.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class WordFocusListeningGenerator {
|
||||
Future<MessageActivityResponse> get(
|
||||
static MessageActivityResponse get(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
) {
|
||||
if (req.targetTokens.length <= 1) {
|
||||
throw Exception(
|
||||
"Word focus listening activity requires at least 2 tokens",
|
||||
);
|
||||
}
|
||||
|
||||
return _matchActivity(req);
|
||||
}
|
||||
|
||||
Future<MessageActivityResponse> _matchActivity(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
return MessageActivityResponse(
|
||||
activity: PracticeActivityModel(
|
||||
activityType: ActivityTypeEnum.wordFocusListening,
|
||||
|
|
@ -46,76 +35,4 @@ class WordFocusListeningGenerator {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> lemmaActivityDistractors(PangeaToken token) async {
|
||||
final List<String> lemmas = MatrixState
|
||||
.pangeaController.getAnalytics.constructListModel
|
||||
.constructList(type: ConstructTypeEnum.vocab)
|
||||
.map((c) => c.lemma)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
// Offload computation to an isolate
|
||||
final Map<String, int> distances =
|
||||
await compute(_computeDistancesInIsolate, {
|
||||
'lemmas': lemmas,
|
||||
'target': token.lemma.text,
|
||||
});
|
||||
|
||||
// Sort lemmas by distance
|
||||
final sortedLemmas = distances.keys.toList()
|
||||
..sort((a, b) => distances[a]!.compareTo(distances[b]!));
|
||||
|
||||
// Take the shortest 4
|
||||
final choices = sortedLemmas.take(4).toList();
|
||||
if (choices.isEmpty) {
|
||||
return [token.lemma.text];
|
||||
}
|
||||
|
||||
if (!choices.contains(token.lemma.text)) {
|
||||
choices.add(token.lemma.text);
|
||||
choices.shuffle();
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
// isolate helper function
|
||||
Map<String, int> _computeDistancesInIsolate(Map<String, dynamic> params) {
|
||||
final List<String> lemmas = params['lemmas'];
|
||||
final String target = params['target'];
|
||||
|
||||
// Calculate Levenshtein distances
|
||||
final Map<String, int> distances = {};
|
||||
for (final lemma in lemmas) {
|
||||
distances[lemma] = levenshteinDistanceSync(target, lemma);
|
||||
}
|
||||
return distances;
|
||||
}
|
||||
|
||||
int levenshteinDistanceSync(String s, String t) {
|
||||
final int m = s.length;
|
||||
final int n = t.length;
|
||||
final List<List<int>> dp = List.generate(
|
||||
m + 1,
|
||||
(_) => List.generate(n + 1, (_) => 0),
|
||||
);
|
||||
|
||||
for (int i = 0; i <= m; i++) {
|
||||
for (int j = 0; j <= n; j++) {
|
||||
if (i == 0) {
|
||||
dp[i][j] = j;
|
||||
} else if (j == 0) {
|
||||
dp[i][j] = i;
|
||||
} else if (s[i - 1] == t[j - 1]) {
|
||||
dp[i][j] = dp[i - 1][j - 1];
|
||||
} else {
|
||||
dp[i][j] = 1 +
|
||||
[dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]]
|
||||
.reduce((a, b) => a < b ? a : b);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return dp[m][n];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
|
||||
enum MessageMode {
|
||||
practiceActivity,
|
||||
|
||||
wordZoom,
|
||||
wordEmoji,
|
||||
wordMeaning,
|
||||
wordMorph,
|
||||
// wordZoomTextToSpeech,
|
||||
// wordZoomSpeechToText,
|
||||
|
||||
messageMeaning,
|
||||
listening,
|
||||
messageSpeechToText,
|
||||
noneSelected;
|
||||
|
||||
// message not selected
|
||||
noneSelected,
|
||||
}
|
||||
|
||||
extension MessageModeExtension on MessageMode {
|
||||
IconData get icon {
|
||||
switch (this) {
|
||||
case MessageMode.listening:
|
||||
return Icons.volume_up;
|
||||
case MessageMode.messageSpeechToText:
|
||||
return Symbols.speech_to_text;
|
||||
case MessageMode.practiceActivity:
|
||||
return Symbols.fitness_center;
|
||||
case MessageMode.wordZoom:
|
||||
case MessageMode.wordMeaning:
|
||||
return Symbols.dictionary;
|
||||
case MessageMode.noneSelected:
|
||||
return Icons.error;
|
||||
case MessageMode.messageMeaning:
|
||||
return Icons.star;
|
||||
case MessageMode.wordEmoji:
|
||||
return Symbols.imagesmode;
|
||||
case MessageMode.wordMorph:
|
||||
|
|
@ -53,44 +28,12 @@ extension MessageModeExtension on MessageMode {
|
|||
}
|
||||
}
|
||||
|
||||
String title(BuildContext context) {
|
||||
switch (this) {
|
||||
case MessageMode.listening:
|
||||
return L10n.of(context).messageAudio;
|
||||
case MessageMode.messageSpeechToText:
|
||||
return L10n.of(context).speechToTextTooltip;
|
||||
case MessageMode.practiceActivity:
|
||||
return L10n.of(context).practice;
|
||||
case MessageMode.wordZoom:
|
||||
return L10n.of(context).vocab;
|
||||
case MessageMode.noneSelected:
|
||||
return '';
|
||||
case MessageMode.messageMeaning:
|
||||
return L10n.of(context).meaning;
|
||||
case MessageMode.wordEmoji:
|
||||
return L10n.of(context).image;
|
||||
case MessageMode.wordMorph:
|
||||
return L10n.of(context).grammar;
|
||||
case MessageMode.wordMeaning:
|
||||
return L10n.of(context).meaning;
|
||||
}
|
||||
}
|
||||
|
||||
String tooltip(BuildContext context) {
|
||||
switch (this) {
|
||||
case MessageMode.listening:
|
||||
return L10n.of(context).listen;
|
||||
case MessageMode.messageSpeechToText:
|
||||
return L10n.of(context).speechToTextTooltip;
|
||||
case MessageMode.practiceActivity:
|
||||
return L10n.of(context).practice;
|
||||
case MessageMode.wordZoom:
|
||||
return L10n.of(context).vocab;
|
||||
case MessageMode.noneSelected:
|
||||
return '';
|
||||
case MessageMode.messageMeaning:
|
||||
return L10n.of(context).meaning;
|
||||
//TODO: add L10n
|
||||
case MessageMode.wordEmoji:
|
||||
return L10n.of(context).image;
|
||||
case MessageMode.wordMorph:
|
||||
|
|
@ -100,106 +43,11 @@ extension MessageModeExtension on MessageMode {
|
|||
}
|
||||
}
|
||||
|
||||
InstructionsEnum? get instructionsEnum {
|
||||
switch (this) {
|
||||
case MessageMode.wordMorph:
|
||||
return InstructionsEnum.chooseMorphs;
|
||||
case MessageMode.messageSpeechToText:
|
||||
return InstructionsEnum.speechToText;
|
||||
case MessageMode.wordMeaning:
|
||||
return InstructionsEnum.chooseLemmaMeaning;
|
||||
case MessageMode.listening:
|
||||
return InstructionsEnum.chooseWordAudio;
|
||||
case MessageMode.wordEmoji:
|
||||
return InstructionsEnum.chooseEmoji;
|
||||
case MessageMode.noneSelected:
|
||||
return InstructionsEnum.readingAssistanceOverview;
|
||||
case MessageMode.messageMeaning:
|
||||
case MessageMode.wordZoom:
|
||||
case MessageMode.practiceActivity:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
double get pointOnBar {
|
||||
switch (this) {
|
||||
// case MessageMode.stats:
|
||||
// return 1;
|
||||
case MessageMode.noneSelected:
|
||||
return 1;
|
||||
case MessageMode.wordMorph:
|
||||
return 0.7;
|
||||
case MessageMode.wordMeaning:
|
||||
return 0.5;
|
||||
case MessageMode.listening:
|
||||
return 0.3;
|
||||
case MessageMode.messageSpeechToText:
|
||||
case MessageMode.wordZoom:
|
||||
case MessageMode.wordEmoji:
|
||||
case MessageMode.messageMeaning:
|
||||
case MessageMode.practiceActivity:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool isUnlocked(
|
||||
MessageOverlayController overlayController,
|
||||
) {
|
||||
switch (this) {
|
||||
case MessageMode.practiceActivity:
|
||||
case MessageMode.listening:
|
||||
case MessageMode.messageSpeechToText:
|
||||
case MessageMode.messageMeaning:
|
||||
case MessageMode.wordZoom:
|
||||
case MessageMode.wordEmoji:
|
||||
case MessageMode.wordMorph:
|
||||
case MessageMode.wordMeaning:
|
||||
case MessageMode.noneSelected:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool get showButton => this != MessageMode.practiceActivity;
|
||||
|
||||
bool isModeDone(MessageOverlayController overlayController) {
|
||||
switch (this) {
|
||||
case MessageMode.listening:
|
||||
return overlayController.isListeningDone;
|
||||
case MessageMode.wordEmoji:
|
||||
return overlayController.isEmojiDone;
|
||||
case MessageMode.wordMorph:
|
||||
return overlayController.isMorphDone;
|
||||
case MessageMode.wordMeaning:
|
||||
return overlayController.isMeaningDone;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Color iconButtonColor(
|
||||
BuildContext context,
|
||||
MessageOverlayController overlayController,
|
||||
) {
|
||||
if (overlayController.isTotallyDone) {
|
||||
return AppConfig.gold;
|
||||
}
|
||||
|
||||
//locked
|
||||
if (!isUnlocked(overlayController)) {
|
||||
return barAndLockedButtonColor(context);
|
||||
}
|
||||
|
||||
//unlocked
|
||||
return isModeDone(overlayController)
|
||||
? AppConfig.gold
|
||||
: Theme.of(context).colorScheme.primaryContainer;
|
||||
}
|
||||
|
||||
static Color barAndLockedButtonColor(BuildContext context) {
|
||||
return Theme.of(context).brightness == Brightness.dark
|
||||
? Colors.grey[800]!
|
||||
: Colors.grey[200]!;
|
||||
}
|
||||
bool done,
|
||||
) =>
|
||||
done ? AppConfig.gold : Theme.of(context).colorScheme.primaryContainer;
|
||||
|
||||
ActivityTypeEnum? get associatedActivityType {
|
||||
switch (this) {
|
||||
|
|
@ -207,163 +55,19 @@ extension MessageModeExtension on MessageMode {
|
|||
return ActivityTypeEnum.wordMeaning;
|
||||
case MessageMode.listening:
|
||||
return ActivityTypeEnum.wordFocusListening;
|
||||
|
||||
case MessageMode.wordEmoji:
|
||||
return ActivityTypeEnum.emoji;
|
||||
|
||||
case MessageMode.wordMorph:
|
||||
return ActivityTypeEnum.morphId;
|
||||
|
||||
case MessageMode.noneSelected:
|
||||
case MessageMode.messageMeaning:
|
||||
case MessageMode.wordZoom:
|
||||
case MessageMode.messageSpeechToText:
|
||||
case MessageMode.practiceActivity:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// returns a nullable string of the current level of the message
|
||||
/// if string is null, then user has completed all levels
|
||||
/// should be resolvable into a part of speech or morph feature using fromString
|
||||
/// of the respective enum, PartOfSpeechEnum or MorphFeatureEnum
|
||||
String? currentChoiceMode(
|
||||
MessageOverlayController overlayController,
|
||||
PangeaMessageEvent pangeaMessage,
|
||||
) {
|
||||
switch (this) {
|
||||
case MessageMode.wordMeaning:
|
||||
case MessageMode.listening:
|
||||
case MessageMode.wordEmoji:
|
||||
// get the pos with some tokens left to practice, from most to least important for learning
|
||||
return pangeaMessage.messageDisplayRepresentation!
|
||||
.posSetToPractice(associatedActivityType!)
|
||||
.firstWhereOrNull(
|
||||
(pos) => pangeaMessage.messageDisplayRepresentation!.tokens!.any(
|
||||
(t) => t.vocabConstructID.isActivityProbablyLevelAppropriate(
|
||||
associatedActivityType!,
|
||||
t.text.content,
|
||||
),
|
||||
),
|
||||
)
|
||||
?.name;
|
||||
|
||||
case MessageMode.wordMorph:
|
||||
// get the morph feature with some tokens left to practice, from most to least important for learning
|
||||
return pangeaMessage
|
||||
.messageDisplayRepresentation!.morphFeatureSetToPractice
|
||||
.firstWhereOrNull(
|
||||
(feature) =>
|
||||
pangeaMessage.messageDisplayRepresentation!.tokens!.any((t) {
|
||||
final String? morphTag = t.getMorphTag(feature);
|
||||
|
||||
if (morphTag == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return ConstructIdentifier(
|
||||
lemma: morphTag,
|
||||
type: ConstructTypeEnum.morph,
|
||||
category: feature.name,
|
||||
).isActivityProbablyLevelAppropriate(
|
||||
associatedActivityType!,
|
||||
t.text.content,
|
||||
);
|
||||
}),
|
||||
)
|
||||
?.name;
|
||||
|
||||
case MessageMode.noneSelected:
|
||||
case MessageMode.messageMeaning:
|
||||
case MessageMode.wordZoom:
|
||||
case MessageMode.messageSpeechToText:
|
||||
case MessageMode.practiceActivity:
|
||||
return null;
|
||||
}
|
||||
|
||||
// final feature = MorphFeaturesEnumExtension.fromString(overlayController);
|
||||
|
||||
// if (feature != null) {
|
||||
// for (int i; i < pangeaMessage.messageDisplayRepresentation!.morphFeatureSetToPractice.length; i++) {
|
||||
// if (pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature).isNotEmpty ?? false) {
|
||||
// return i;
|
||||
// }
|
||||
// }
|
||||
|
||||
// for (final feature in pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature)) ?? []) {
|
||||
// if (pangeaMessage.messageDisplayRepresentation?.tagsByFeature(feature).isNotEmpty ?? false) {
|
||||
// return feature.index;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
// List<MessageModeChoiceLevelWidget> messageModeChoiceLevel(
|
||||
// MessageOverlayController overlayController,
|
||||
// PangeaMessageEvent pangeaMessage,
|
||||
// ) {
|
||||
// switch (this) {
|
||||
// case MessageMode.wordMorph:
|
||||
// final morphFeatureSet = pangeaMessage
|
||||
// .messageDisplayRepresentation?.morphFeatureSetToPractice;
|
||||
|
||||
// if (morphFeatureSet == null) {
|
||||
// debugger(when: kDebugMode);
|
||||
// return [];
|
||||
// }
|
||||
|
||||
// // sort by the list of priority of parts of speech, defined by their order in the enum
|
||||
// morphFeatureSet.toList().sort((a, b) => a.index.compareTo(b.index));
|
||||
|
||||
// debugPrint(
|
||||
// "morphFeatureSet: ${morphFeatureSet.map((e) => e.name).toList()}",
|
||||
// );
|
||||
// return morphFeatureSet
|
||||
// .map(
|
||||
// (feature) => MessageModeChoiceLevelWidget(
|
||||
// overlayController: overlayController,
|
||||
// pangeaMessageEvent: pangeaMessage,
|
||||
// morphFeature: feature,
|
||||
// ),
|
||||
// )
|
||||
// .toList();
|
||||
// case MessageMode.noneSelected:
|
||||
// case MessageMode.messageMeaning:
|
||||
// case MessageMode.messageTranslation:
|
||||
// case MessageMode.messageTextToSpeech:
|
||||
// case MessageMode.messageSpeechToText:
|
||||
// case MessageMode.practiceActivity:
|
||||
// case MessageMode.wordZoom:
|
||||
// case MessageMode.wordMeaning:
|
||||
// case MessageMode.wordEmoji:
|
||||
// if (associatedActivityType == null) {
|
||||
// debugger(when: kDebugMode);
|
||||
// return [];
|
||||
// }
|
||||
// final posSet = pangeaMessage.messageDisplayRepresentation
|
||||
// ?.posSetToPractice(associatedActivityType!);
|
||||
|
||||
// if (posSet == null) {
|
||||
// debugger(when: kDebugMode);
|
||||
// return [];
|
||||
// }
|
||||
|
||||
// // sort by the list of priority of parts of speech, defined by their order in the enum
|
||||
// posSet.toList().sort((a, b) => a.index.compareTo(b.index));
|
||||
|
||||
// debugPrint("posSet: ${posSet.map((e) => e.name).toList()}");
|
||||
|
||||
// final widgets = posSet
|
||||
// .map(
|
||||
// (pos) => MessageModeChoiceLevelWidget(
|
||||
// partOfSpeech: pos,
|
||||
// overlayController: overlayController,
|
||||
// pangeaMessageEvent: pangeaMessage,
|
||||
// ),
|
||||
// )
|
||||
// .toList();
|
||||
|
||||
// return widgets;
|
||||
// }
|
||||
// }
|
||||
static List<MessageMode> get practiceModes => [
|
||||
MessageMode.listening,
|
||||
MessageMode.wordMorph,
|
||||
MessageMode.wordMeaning,
|
||||
MessageMode.wordEmoji,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,79 +0,0 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_record_event.dart';
|
||||
import '../../events/constants/pangea_event_types.dart';
|
||||
|
||||
class PracticeActivityEvent {
|
||||
Event event;
|
||||
Timeline? timeline;
|
||||
PracticeActivityModel? _content;
|
||||
|
||||
PracticeActivityEvent({
|
||||
required this.event,
|
||||
required this.timeline,
|
||||
content,
|
||||
}) {
|
||||
if (content != null) {
|
||||
if (!kDebugMode) {
|
||||
throw Exception(
|
||||
"content should not be set on product, just a dev placeholder",
|
||||
);
|
||||
} else {
|
||||
_content = content;
|
||||
}
|
||||
}
|
||||
if (event.type != PangeaEventTypes.pangeaActivity) {
|
||||
throw Exception(
|
||||
"${event.type} should not be used to make a PracticeActivityEvent",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PracticeActivityModel get practiceActivity {
|
||||
_content ??= event.getPangeaContent<PracticeActivityModel>();
|
||||
return _content!;
|
||||
}
|
||||
|
||||
/// All completion records assosiated with this activity
|
||||
List<PracticeActivityRecordEvent> get allRecords {
|
||||
if (timeline == null) {
|
||||
debugger(when: kDebugMode);
|
||||
return [];
|
||||
}
|
||||
final List<Event> records = event
|
||||
.aggregatedEvents(timeline!, PangeaEventTypes.activityRecord)
|
||||
.toList();
|
||||
|
||||
return records
|
||||
.map((event) => PracticeActivityRecordEvent(event: event))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/// Completion record assosiated with this activity
|
||||
/// for the logged in user, null if there is none
|
||||
// List<PracticeActivityRecordEvent> get allUserRecords => allRecords
|
||||
// .where(
|
||||
// (recordEvent) =>
|
||||
// recordEvent.event.senderId == recordEvent.event.room.client.userID,
|
||||
// )
|
||||
// .toList();
|
||||
|
||||
/// Get the most recent user record for this activity
|
||||
// PracticeActivityRecordEvent? get latestUserRecord {
|
||||
// final List<PracticeActivityRecordEvent> userRecords = allUserRecords;
|
||||
// if (userRecords.isEmpty) return null;
|
||||
// return userRecords.reduce(
|
||||
// (a, b) => a.event.originServerTs.isAfter(b.event.originServerTs) ? a : b,
|
||||
// );
|
||||
// }
|
||||
|
||||
// DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs;
|
||||
|
||||
String get parentMessageId => event.relationshipEventId!;
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_record.dart';
|
||||
import '../../events/constants/pangea_event_types.dart';
|
||||
|
||||
class PracticeActivityRecordEvent {
|
||||
Event event;
|
||||
|
||||
PracticeRecord? _content;
|
||||
|
||||
PracticeActivityRecordEvent({required this.event}) {
|
||||
if (event.type != PangeaEventTypes.activityRecord) {
|
||||
throw Exception(
|
||||
"${event.type} should not be used to make a PracticeActivityRecordEvent",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PracticeRecord get record {
|
||||
_content ??= event.getPangeaContent<PracticeRecord>();
|
||||
return _content!;
|
||||
}
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@ import 'package:fluffychat/pangea/morphs/morph_icon.dart';
|
|||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_choice.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice_item.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart';
|
||||
|
||||
// this widget will handle the content of the input bar when mode == MessageMode.wordMorph
|
||||
|
||||
|
|
@ -29,13 +29,17 @@ import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart
|
|||
const int numberOfMorphDistractors = 3;
|
||||
|
||||
class MessageMorphInputBarContent extends StatefulWidget {
|
||||
final MessageOverlayController overlayController;
|
||||
final PracticeController controller;
|
||||
final PracticeActivityModel activity;
|
||||
final PangeaToken? selectedToken;
|
||||
final double maxWidth;
|
||||
|
||||
const MessageMorphInputBarContent({
|
||||
super.key,
|
||||
required this.overlayController,
|
||||
required this.controller,
|
||||
required this.activity,
|
||||
required this.selectedToken,
|
||||
required this.maxWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -47,25 +51,19 @@ class MessageMorphInputBarContentState
|
|||
extends State<MessageMorphInputBarContent> {
|
||||
String? selectedTag;
|
||||
|
||||
MessageOverlayController get overlay => widget.overlayController;
|
||||
PangeaToken get token => widget.activity.targetTokens.first;
|
||||
MorphFeaturesEnum get morph => widget.activity.morphFeature!;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MessageMorphInputBarContent oldWidget) {
|
||||
if (morph != oldWidget.overlayController.selectedMorph?.morph ||
|
||||
token != oldWidget.overlayController.selectedToken) {
|
||||
final selected = widget.controller.selectedMorph?.morph;
|
||||
if (morph != selected || token != oldWidget.selectedToken) {
|
||||
setState(() {});
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
TextStyle? textStyle(BuildContext context) => overlay.maxWidth > 600
|
||||
TextStyle? textStyle(BuildContext context) => widget.maxWidth > 600
|
||||
? Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
)
|
||||
|
|
@ -75,14 +73,14 @@ class MessageMorphInputBarContentState
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final iconSize = overlay.maxWidth > 600
|
||||
final iconSize = widget.maxWidth > 600
|
||||
? 28.0
|
||||
: overlay.maxWidth > 600
|
||||
: widget.maxWidth > 600
|
||||
? 24.0
|
||||
: 16.0;
|
||||
final spacing = overlay.maxWidth > 600
|
||||
final spacing = widget.maxWidth > 600
|
||||
? 16.0
|
||||
: overlay.maxWidth > 600
|
||||
: widget.maxWidth > 600
|
||||
? 8.0
|
||||
: 4.0;
|
||||
|
||||
|
|
@ -132,7 +130,7 @@ class MessageMorphInputBarContentState
|
|||
),
|
||||
onTap: () {
|
||||
setState(() => selectedTag = choice);
|
||||
widget.overlayController.onMatch(
|
||||
widget.controller.onMatch(
|
||||
token,
|
||||
PracticeChoice(
|
||||
choiceContent: choice,
|
||||
|
|
@ -156,13 +154,13 @@ class MessageMorphInputBarContentState
|
|||
if (selectedTag != null)
|
||||
Container(
|
||||
constraints: BoxConstraints(
|
||||
minHeight: overlay.maxWidth > 600 ? 20 : 34,
|
||||
minHeight: widget.maxWidth > 600 ? 20 : 34,
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: MorphMeaningWidget(
|
||||
feature: morph,
|
||||
tag: selectedTag!,
|
||||
style: overlay.maxWidth > 600
|
||||
style: widget.maxWidth > 600
|
||||
? Theme.of(context).textTheme.bodyLarge
|
||||
: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -13,16 +13,16 @@ import 'package:fluffychat/pangea/practice_activities/practice_choice.dart';
|
|||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/practice_match_item.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart';
|
||||
|
||||
class MatchActivityCard extends StatelessWidget {
|
||||
final PracticeActivityModel currentActivity;
|
||||
final MessageOverlayController overlayController;
|
||||
final PracticeController controller;
|
||||
|
||||
const MatchActivityCard({
|
||||
super.key,
|
||||
required this.currentActivity,
|
||||
required this.overlayController,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
PracticeActivityModel get activity => currentActivity;
|
||||
|
|
@ -59,8 +59,8 @@ class MatchActivityCard extends StatelessWidget {
|
|||
: Theme.of(context).textTheme.titleMedium?.fontSize) ??
|
||||
26;
|
||||
|
||||
if (overlayController.toolbarMode == MessageMode.listening ||
|
||||
overlayController.toolbarMode == MessageMode.wordEmoji) {
|
||||
final mode = controller.practiceMode;
|
||||
if (mode == MessageMode.listening || mode == MessageMode.wordEmoji) {
|
||||
fontSize = fontSize * 1.5;
|
||||
}
|
||||
|
||||
|
|
@ -69,10 +69,8 @@ class MatchActivityCard extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.max,
|
||||
spacing: 4.0,
|
||||
children: [
|
||||
if (overlayController.toolbarMode == MessageMode.listening)
|
||||
MessageAudioCard(
|
||||
overlayController: overlayController,
|
||||
),
|
||||
if (mode == MessageMode.listening)
|
||||
MessageAudioCard(messageEvent: controller.pangeaMessageEvent),
|
||||
Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 4.0,
|
||||
|
|
@ -82,13 +80,13 @@ class MatchActivityCard extends StatelessWidget {
|
|||
final bool? wasCorrect =
|
||||
currentActivity.practiceTarget.wasCorrectMatch(cf);
|
||||
return ChoiceAnimationWidget(
|
||||
isSelected: overlayController.selectedChoice == cf,
|
||||
isSelected: controller.selectedChoice == cf,
|
||||
isCorrect: wasCorrect,
|
||||
child: PracticeMatchItem(
|
||||
token: currentActivity.practiceTarget.tokens.firstWhereOrNull(
|
||||
(t) => t.vocabConstructID == cf.form.cId,
|
||||
),
|
||||
isSelected: overlayController.selectedChoice == cf,
|
||||
isSelected: controller.selectedChoice == cf,
|
||||
isCorrect: wasCorrect,
|
||||
constructForm: cf,
|
||||
content: choiceDisplayContent(cf.choiceContent, fontSize),
|
||||
|
|
@ -96,7 +94,7 @@ class MatchActivityCard extends StatelessWidget {
|
|||
activityType == ActivityTypeEnum.wordFocusListening
|
||||
? cf.choiceContent
|
||||
: null,
|
||||
overlayController: overlayController,
|
||||
controller: controller,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -8,10 +8,18 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
|||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_choice.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class PracticeMatchItem extends StatefulWidget {
|
||||
final Widget content;
|
||||
final PangeaToken? token;
|
||||
final PracticeChoice constructForm;
|
||||
final String? audioContent;
|
||||
final PracticeController controller;
|
||||
final bool? isCorrect;
|
||||
final bool isSelected;
|
||||
|
||||
const PracticeMatchItem({
|
||||
super.key,
|
||||
required this.content,
|
||||
|
|
@ -20,17 +28,9 @@ class PracticeMatchItem extends StatefulWidget {
|
|||
required this.isCorrect,
|
||||
required this.isSelected,
|
||||
this.audioContent,
|
||||
required this.overlayController,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
final Widget content;
|
||||
final PangeaToken? token;
|
||||
final PracticeChoice constructForm;
|
||||
final String? audioContent;
|
||||
final MessageOverlayController overlayController;
|
||||
final bool? isCorrect;
|
||||
final bool isSelected;
|
||||
|
||||
@override
|
||||
PracticeMatchItemState createState() => PracticeMatchItemState();
|
||||
}
|
||||
|
|
@ -110,9 +110,9 @@ class PracticeMatchItemState extends State<PracticeMatchItem> {
|
|||
|
||||
void onTap() {
|
||||
play();
|
||||
isCorrect == null || !isCorrect! || widget.token == null
|
||||
? widget.overlayController.onChoiceSelect(widget.constructForm)
|
||||
: widget.overlayController.updateSelectedSpan(widget.token!.text);
|
||||
if (isCorrect == null || !isCorrect! || widget.token == null) {
|
||||
widget.controller.onChoiceSelect(widget.constructForm);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
|||
|
|
@ -2,19 +2,25 @@ import 'package:flutter/material.dart';
|
|||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_mode_buttons.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
const double minContentHeight = 120;
|
||||
|
||||
class ReadingAssistanceInputBar extends StatefulWidget {
|
||||
final MessageOverlayController overlayController;
|
||||
final PracticeController controller;
|
||||
final PangeaToken? selectedToken;
|
||||
final double maxWidth;
|
||||
|
||||
const ReadingAssistanceInputBar(
|
||||
this.overlayController, {
|
||||
this.controller, {
|
||||
required this.maxWidth,
|
||||
required this.selectedToken,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -25,7 +31,6 @@ class ReadingAssistanceInputBar extends StatefulWidget {
|
|||
|
||||
class ReadingAssistanceInputBarState extends State<ReadingAssistanceInputBar> {
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
MessageOverlayController get overlayController => widget.overlayController;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
|
|
@ -33,129 +38,166 @@ class ReadingAssistanceInputBarState extends State<ReadingAssistanceInputBar> {
|
|||
super.dispose();
|
||||
}
|
||||
|
||||
Widget barContent(BuildContext context) {
|
||||
Widget? content;
|
||||
final target = overlayController.toolbarMode.associatedActivityType != null
|
||||
? overlayController.practiceSelection?.getSelection(
|
||||
overlayController.toolbarMode.associatedActivityType!,
|
||||
overlayController.selectedMorph?.token,
|
||||
overlayController.selectedMorph?.morph,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (overlayController.pangeaMessageEvent.isAudioMessage == true) {
|
||||
return const SizedBox();
|
||||
// return ReactionsPicker(controller);
|
||||
} else {
|
||||
final activityType = overlayController.toolbarMode.associatedActivityType;
|
||||
final activityCompleted = activityType != null &&
|
||||
overlayController.isPracticeActivityDone(activityType);
|
||||
|
||||
switch (overlayController.toolbarMode) {
|
||||
case MessageMode.messageSpeechToText:
|
||||
case MessageMode.practiceActivity:
|
||||
case MessageMode.wordZoom:
|
||||
case MessageMode.noneSelected:
|
||||
case MessageMode.messageMeaning:
|
||||
content = overlayController.isTotallyDone
|
||||
? const AllDoneWidget()
|
||||
: Text(
|
||||
L10n.of(context).choosePracticeMode,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(fontStyle: FontStyle.italic),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
|
||||
case MessageMode.wordEmoji:
|
||||
case MessageMode.wordMeaning:
|
||||
case MessageMode.listening:
|
||||
if (overlayController.isTotallyDone) {
|
||||
content = const AllDoneWidget();
|
||||
} else if (target == null || activityCompleted) {
|
||||
content = Text(
|
||||
L10n.of(context).practiceActivityCompleted,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
} else {
|
||||
content = PracticeActivityCard(
|
||||
targetTokensAndActivityType: target,
|
||||
overlayController: overlayController,
|
||||
);
|
||||
}
|
||||
case MessageMode.wordMorph:
|
||||
if (overlayController.isTotallyDone) {
|
||||
content = const AllDoneWidget();
|
||||
} else if (activityCompleted) {
|
||||
content = Text(
|
||||
L10n.of(context).practiceActivityCompleted,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
} else if (target != null) {
|
||||
content = PracticeActivityCard(
|
||||
targetTokensAndActivityType: target,
|
||||
overlayController: overlayController,
|
||||
);
|
||||
} else {
|
||||
content = Center(
|
||||
child: Text(
|
||||
L10n.of(context).selectForGrammar,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
PracticeModeButtons(
|
||||
overlayController: overlayController,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
alignment: Alignment.center,
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: minContentHeight,
|
||||
maxHeight: AppConfig.readingAssistanceInputBarHeight,
|
||||
),
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: SizedBox(
|
||||
width: overlayController.maxWidth,
|
||||
child: barContent(context),
|
||||
return ListenableBuilder(
|
||||
listenable: widget.controller,
|
||||
builder: (context, _) {
|
||||
return Column(
|
||||
spacing: 4.0,
|
||||
children: [
|
||||
Row(
|
||||
spacing: 4.0,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
...MessageMode.practiceModes.map(
|
||||
(m) => ToolbarButton(
|
||||
mode: m,
|
||||
setMode: () => widget.controller.updateToolbarMode(m),
|
||||
isComplete: widget.controller.isPracticeActivityDone(
|
||||
m.associatedActivityType!,
|
||||
),
|
||||
isSelected: widget.controller.practiceMode == m,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Material(
|
||||
borderRadius: BorderRadius.circular(AppConfig.borderRadius),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
alignment: Alignment.center,
|
||||
constraints: const BoxConstraints(
|
||||
minHeight: minContentHeight,
|
||||
maxHeight: AppConfig.readingAssistanceInputBarHeight,
|
||||
),
|
||||
child: Scrollbar(
|
||||
thumbVisibility: true,
|
||||
controller: _scrollController,
|
||||
child: SingleChildScrollView(
|
||||
controller: _scrollController,
|
||||
child: SizedBox(
|
||||
width: widget.maxWidth,
|
||||
child: _ReadingAssistanceBarContent(
|
||||
controller: widget.controller,
|
||||
selectedToken: widget.selectedToken,
|
||||
maxWidth: widget.maxWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AllDoneWidget extends StatelessWidget {
|
||||
const AllDoneWidget({
|
||||
super.key,
|
||||
class _ReadingAssistanceBarContent extends StatelessWidget {
|
||||
final PracticeController controller;
|
||||
final PangeaToken? selectedToken;
|
||||
final double maxWidth;
|
||||
|
||||
const _ReadingAssistanceBarContent({
|
||||
required this.controller,
|
||||
required this.selectedToken,
|
||||
required this.maxWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mode = controller.practiceMode;
|
||||
if (controller.pangeaMessageEvent.isAudioMessage == true) {
|
||||
return const SizedBox();
|
||||
}
|
||||
final activityType = mode.associatedActivityType;
|
||||
final activityCompleted =
|
||||
activityType != null && controller.isPracticeActivityDone(activityType);
|
||||
|
||||
switch (mode) {
|
||||
case MessageMode.noneSelected:
|
||||
return controller.isTotallyDone
|
||||
? const _AllDoneWidget()
|
||||
: Text(
|
||||
L10n.of(context).choosePracticeMode,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.bodyLarge
|
||||
?.copyWith(fontStyle: FontStyle.italic),
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
|
||||
case MessageMode.wordEmoji:
|
||||
case MessageMode.wordMeaning:
|
||||
case MessageMode.listening:
|
||||
if (controller.isTotallyDone) {
|
||||
return const _AllDoneWidget();
|
||||
}
|
||||
|
||||
final target = controller.practiceSelection?.getTarget(activityType!);
|
||||
if (target == null || activityCompleted) {
|
||||
return Text(
|
||||
L10n.of(context).practiceActivityCompleted,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
return PracticeActivityCard(
|
||||
targetTokensAndActivityType: target,
|
||||
controller: controller,
|
||||
selectedToken: selectedToken,
|
||||
maxWidth: maxWidth,
|
||||
);
|
||||
case MessageMode.wordMorph:
|
||||
if (controller.isTotallyDone) {
|
||||
return const _AllDoneWidget();
|
||||
}
|
||||
if (activityCompleted) {
|
||||
return Text(
|
||||
L10n.of(context).practiceActivityCompleted,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
);
|
||||
}
|
||||
|
||||
PracticeTarget? target;
|
||||
if (controller.practiceSelection != null &&
|
||||
controller.selectedMorph != null) {
|
||||
target = controller.practiceSelection!.getMorphTarget(
|
||||
controller.selectedMorph!.token,
|
||||
controller.selectedMorph!.morph,
|
||||
);
|
||||
}
|
||||
|
||||
if (target == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
L10n.of(context).selectForGrammar,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return PracticeActivityCard(
|
||||
targetTokensAndActivityType: target,
|
||||
controller: controller,
|
||||
selectedToken: selectedToken,
|
||||
maxWidth: maxWidth,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _AllDoneWidget extends StatelessWidget {
|
||||
const _AllDoneWidget();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
|
|
|
|||
|
|
@ -12,15 +12,14 @@ import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
|||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/events/extensions/pangea_event_extension.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
|
||||
class MessageAudioCard extends StatefulWidget {
|
||||
final MessageOverlayController overlayController;
|
||||
final PangeaMessageEvent messageEvent;
|
||||
final VoidCallback? onError;
|
||||
|
||||
const MessageAudioCard({
|
||||
super.key,
|
||||
required this.overlayController,
|
||||
required this.messageEvent,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
|
|
@ -38,24 +37,21 @@ class MessageAudioCardState extends State<MessageAudioCard> {
|
|||
fetchAudio();
|
||||
}
|
||||
|
||||
PangeaMessageEvent get messageEvent =>
|
||||
widget.overlayController.pangeaMessageEvent;
|
||||
|
||||
Future<void> fetchAudio() async {
|
||||
if (!mounted) return;
|
||||
setState(() => _isLoading = true);
|
||||
|
||||
try {
|
||||
final String langCode = messageEvent.messageDisplayLangCode;
|
||||
final Event? localEvent = messageEvent.getTextToSpeechLocal(
|
||||
final String langCode = widget.messageEvent.messageDisplayLangCode;
|
||||
final Event? localEvent = widget.messageEvent.getTextToSpeechLocal(
|
||||
langCode,
|
||||
messageEvent.messageDisplayText,
|
||||
widget.messageEvent.messageDisplayText,
|
||||
);
|
||||
|
||||
if (localEvent != null) {
|
||||
audioFile = await localEvent.getPangeaAudioFile();
|
||||
} else {
|
||||
audioFile = await messageEvent.getMatrixAudioFile(
|
||||
audioFile = await widget.messageEvent.getMatrixAudioFile(
|
||||
langCode,
|
||||
);
|
||||
}
|
||||
|
|
@ -70,7 +66,7 @@ class MessageAudioCardState extends State<MessageAudioCard> {
|
|||
m: 'something wrong getting audio in MessageAudioCardState',
|
||||
data: {
|
||||
'widget.messageEvent.messageDisplayLangCode':
|
||||
messageEvent.messageDisplayLangCode,
|
||||
widget.messageEvent.messageDisplayLangCode,
|
||||
},
|
||||
);
|
||||
if (mounted) setState(() => _isLoading = false);
|
||||
|
|
@ -84,14 +80,12 @@ class MessageAudioCardState extends State<MessageAudioCard> {
|
|||
: audioFile != null
|
||||
? AudioPlayerWidget(
|
||||
null,
|
||||
eventId: "${messageEvent.eventId}_practice",
|
||||
roomId: messageEvent.room.id,
|
||||
senderId: messageEvent.senderId,
|
||||
eventId: "${widget.messageEvent.eventId}_practice",
|
||||
roomId: widget.messageEvent.room.id,
|
||||
senderId: widget.messageEvent.senderId,
|
||||
matrixFile: audioFile,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor,
|
||||
chatController: widget.overlayController.widget.chatController,
|
||||
overlayController: widget.overlayController,
|
||||
linkColor: Theme.of(context).brightness == Brightness.light
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onPrimary,
|
||||
|
|
|
|||
|
|
@ -1,45 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
|
||||
class MessageMeaningCard extends StatelessWidget {
|
||||
final MessageOverlayController controller;
|
||||
|
||||
const MessageMeaningCard({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: AppConfig.toolbarMinWidth,
|
||||
maxHeight: AppConfig.toolbarMaxHeight,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 16),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.sports_martial_arts,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Flexible(
|
||||
child: TextButton(
|
||||
onPressed: () => controller.onRequestForMeaningChallenge(),
|
||||
child: Text(
|
||||
L10n.of(context).clickForMeaningActivity,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -22,18 +22,9 @@ import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
|||
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_emoji_picker.dart';
|
||||
import 'package:fluffychat/pangea/message_token_text/tokens_util.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_choice.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_selection.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/controllers/text_to_speech_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/reading_assistance_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_positioner.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_buttons.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/select_mode_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/word_zoom/lemma_meaning_builder.dart';
|
||||
|
|
@ -70,27 +61,10 @@ class MessageSelectionOverlay extends StatefulWidget {
|
|||
class MessageOverlayController extends State<MessageSelectionOverlay>
|
||||
with SingleTickerProviderStateMixin {
|
||||
Event get event => widget._event;
|
||||
/////////////////////////////////////
|
||||
/// Variables
|
||||
/////////////////////////////////////
|
||||
MessageMode toolbarMode = MessageMode.noneSelected;
|
||||
|
||||
/// set and cleared by the PracticeActivityCard
|
||||
/// has to be at this level so drag targets can access it
|
||||
PracticeActivityModel? activity;
|
||||
|
||||
/// selectedMorph is used for morph activities
|
||||
MorphSelection? selectedMorph;
|
||||
|
||||
/// tracks selected choice
|
||||
PracticeChoice? selectedChoice;
|
||||
|
||||
PangeaTokenText? _selectedSpan;
|
||||
|
||||
List<PangeaTokenText>? _highlightedTokens;
|
||||
bool initialized = false;
|
||||
|
||||
ReadingAssistanceMode? readingAssistanceMode; // default mode
|
||||
|
||||
double maxWidth = AppConfig.toolbarMinWidth;
|
||||
|
||||
|
|
@ -98,6 +72,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
ValueNotifier<SelectMode?> get selectedMode =>
|
||||
selectModeController.selectedMode;
|
||||
|
||||
late PracticeController practiceController;
|
||||
|
||||
/////////////////////////////////////
|
||||
/// Lifecycle
|
||||
/////////////////////////////////////
|
||||
|
|
@ -106,7 +82,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
void initState() {
|
||||
super.initState();
|
||||
selectModeController = SelectModeController(pangeaMessageEvent);
|
||||
initializeTokensAndMode();
|
||||
practiceController = PracticeController(pangeaMessageEvent);
|
||||
_initializeTokensAndMode();
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => widget.chatController.setSelectedEvent(event),
|
||||
);
|
||||
|
|
@ -118,10 +95,11 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
(_) => widget.chatController.clearSelectedEvents(),
|
||||
);
|
||||
selectModeController.dispose();
|
||||
practiceController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> initializeTokensAndMode() async {
|
||||
Future<void> _initializeTokensAndMode() async {
|
||||
try {
|
||||
if (pangeaMessageEvent.event.messageType != MessageTypes.Text) {
|
||||
return;
|
||||
|
|
@ -154,51 +132,19 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
);
|
||||
} finally {
|
||||
_initializeSelectedToken();
|
||||
_setInitialToolbarMode();
|
||||
initialized = true;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _setInitialToolbarMode() async {
|
||||
// 1) if we have a hidden word activity, then we should start with that
|
||||
if (practiceSelection?.hasHiddenWordActivity ?? false) {
|
||||
updateToolbarMode(MessageMode.practiceActivity);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// Decides whether an _initialSelectedToken should be used
|
||||
/// for a first practice activity on the word meaning
|
||||
Future<void> _initializeSelectedToken() async {
|
||||
// if there is no initial selected token, then we don't need to do anything
|
||||
if (widget._initialSelectedToken == null || practiceSelection == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// should not already be involved in a hidden word activity
|
||||
// final isInHiddenWordActivity =
|
||||
// messageAnalyticsEntry!.isTokenInHiddenWordActivity(
|
||||
// widget._initialSelectedToken!,
|
||||
// );
|
||||
|
||||
// // whether the activity should generally be involved in an activity
|
||||
if (practiceSelection?.hasHiddenWordActivity == true) {
|
||||
if (widget._initialSelectedToken == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelectedSpan(widget._initialSelectedToken!.text);
|
||||
|
||||
// int retries = 0;
|
||||
// while (retries < 5 &&
|
||||
// selectedToken != null &&
|
||||
// !MatrixState.pAnyState.isOverlayOpen(
|
||||
// selectedToken!.text.uniqueKey,
|
||||
// )) {
|
||||
// await Future.delayed(const Duration(milliseconds: 100));
|
||||
// _showReadingAssistanceContent();
|
||||
// retries++;
|
||||
// }
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
|
|
@ -255,9 +201,9 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
}
|
||||
|
||||
if (selectedSpan == _selectedSpan) return;
|
||||
if (selectedMorph != null) {
|
||||
selectedMorph = null;
|
||||
}
|
||||
// if (selectedMorph != null) {
|
||||
// selectedMorph = null;
|
||||
// }
|
||||
|
||||
_selectedSpan = selectedSpan;
|
||||
if (selectedMode.value == SelectMode.emoji && selectedToken != null) {
|
||||
|
|
@ -265,125 +211,16 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
if (selectedToken != null) onSelectNewToken(selectedToken!);
|
||||
if (selectedToken != null) _onSelectNewToken(selectedToken!);
|
||||
}
|
||||
}
|
||||
|
||||
void updateToolbarMode(MessageMode mode) => setState(() {
|
||||
selectedChoice = null;
|
||||
|
||||
// close overlay of any selected token
|
||||
if (_selectedSpan != null) {
|
||||
updateSelectedSpan(_selectedSpan!);
|
||||
}
|
||||
|
||||
toolbarMode = mode;
|
||||
if (toolbarMode != MessageMode.wordMorph) {
|
||||
selectedMorph = null;
|
||||
}
|
||||
});
|
||||
|
||||
///////////////////////////////////
|
||||
/// User action handlers
|
||||
/////////////////////////////////////
|
||||
void onRequestForMeaningChallenge() {
|
||||
if (practiceSelection == null) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
e: "MessageAnalyticsEntry is null in onRequestForMeaningChallenge",
|
||||
data: {},
|
||||
);
|
||||
return;
|
||||
}
|
||||
practiceSelection!.addMessageMeaningActivity();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void onChoiceSelect(PracticeChoice? choice, [bool force = false]) {
|
||||
if (selectedChoice == choice && !force) {
|
||||
selectedChoice = null;
|
||||
} else {
|
||||
selectedChoice = choice;
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void onMorphActivitySelect(MorphSelection newMorph) {
|
||||
toolbarMode = MessageMode.wordMorph;
|
||||
// // close overlay of previous token
|
||||
if (_selectedSpan != null && _selectedSpan != newMorph.token.text) {
|
||||
updateSelectedSpan(_selectedSpan!);
|
||||
}
|
||||
selectedMorph = newMorph;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
void onMatch(PangeaToken token, PracticeChoice choice) {
|
||||
if (activity == null) return;
|
||||
activity!.activityType == ActivityTypeEnum.morphId
|
||||
? activity!.onMultipleChoiceSelect(
|
||||
token,
|
||||
choice,
|
||||
pangeaMessageEvent,
|
||||
() => setState(() {}),
|
||||
)
|
||||
: activity!.onMatch(
|
||||
token,
|
||||
choice,
|
||||
pangeaMessageEvent,
|
||||
() => setState(() {}),
|
||||
);
|
||||
|
||||
if (isTotallyDone) {
|
||||
OverlayUtil.showStarRainOverlay(context);
|
||||
}
|
||||
}
|
||||
|
||||
/////////////////////////////////////
|
||||
/// Getters
|
||||
////////////////////////////////////
|
||||
PangeaMessageEvent get pangeaMessageEvent => PangeaMessageEvent(
|
||||
event: widget._event,
|
||||
timeline: widget._timeline,
|
||||
ownMessage: widget._event.room.client.userID == widget._event.senderId,
|
||||
);
|
||||
|
||||
bool get hideWordCardContent =>
|
||||
readingAssistanceMode == ReadingAssistanceMode.practiceMode;
|
||||
|
||||
bool isPracticeActivityDone(ActivityTypeEnum activityType) =>
|
||||
practiceSelection?.activities(activityType).every((a) => a.isComplete) ==
|
||||
true;
|
||||
|
||||
bool get isEmojiDone => isPracticeActivityDone(ActivityTypeEnum.emoji);
|
||||
|
||||
bool get isMeaningDone =>
|
||||
isPracticeActivityDone(ActivityTypeEnum.wordMeaning);
|
||||
|
||||
bool get isListeningDone =>
|
||||
isPracticeActivityDone(ActivityTypeEnum.wordFocusListening);
|
||||
|
||||
bool get isMorphDone => isPracticeActivityDone(ActivityTypeEnum.morphId);
|
||||
|
||||
bool get isTotallyDone =>
|
||||
isEmojiDone && isMeaningDone && isListeningDone && isMorphDone;
|
||||
|
||||
PracticeSelection? get practiceSelection =>
|
||||
pangeaMessageEvent.messageDisplayRepresentation?.tokens != null
|
||||
? PracticeSelectionRepo.get(
|
||||
pangeaMessageEvent.messageDisplayLangCode,
|
||||
pangeaMessageEvent.messageDisplayRepresentation!.tokens!,
|
||||
)
|
||||
: null;
|
||||
|
||||
bool get messageInUserL2 =>
|
||||
pangeaMessageEvent.messageDisplayLangCode.split("-")[0] ==
|
||||
MatrixState.pangeaController.languageController.userL2?.langCodeShort;
|
||||
|
||||
PangeaToken? get selectedToken {
|
||||
if (pangeaMessageEvent.isAudioMessage == true) {
|
||||
final stt = pangeaMessageEvent.getSpeechToTextLocal();
|
||||
|
|
@ -410,10 +247,6 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
return event.messageType == MessageTypes.Audio;
|
||||
}
|
||||
|
||||
///////////////////////////////////
|
||||
/// Functions
|
||||
/////////////////////////////////////
|
||||
|
||||
/// If sentence TTS is playing a word, highlight that word in message overlay
|
||||
void highlightCurrentText(int currentPosition, List<TTSToken> ttsTokens) {
|
||||
final List<TTSToken> textToSelect = [];
|
||||
|
|
@ -451,57 +284,26 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
);
|
||||
}
|
||||
|
||||
/// When an activity is completed, we need to update the state
|
||||
/// and check if the toolbar should be unlocked
|
||||
void onActivityFinish(ActivityTypeEnum activityType, PangeaToken? token) {
|
||||
// if (selectedToken == null) {
|
||||
// updateToolbarMode(MessageMode.noneSelected);
|
||||
// }
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
/// In some cases, we need to exit the practice flow and let the user
|
||||
/// interact with the toolbar without completing activities
|
||||
void exitPracticeFlow() {
|
||||
practiceSelection?.exitPracticeFlow();
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
PracticeTarget? practiceTargetForToken(PangeaToken token) {
|
||||
if (toolbarMode.associatedActivityType == null) return null;
|
||||
return practiceSelection
|
||||
?.activities(toolbarMode.associatedActivityType!)
|
||||
.firstWhereOrNull((a) => a.tokens.contains(token));
|
||||
}
|
||||
|
||||
void onClickOverlayMessageToken(
|
||||
PangeaToken token,
|
||||
) {
|
||||
if (practiceSelection?.hasHiddenWordActivity == true ||
|
||||
readingAssistanceMode == ReadingAssistanceMode.practiceMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
/// we don't want to associate the audio with the text in this mode
|
||||
if (practiceSelection?.hasActiveActivityByToken(
|
||||
ActivityTypeEnum.wordFocusListening,
|
||||
token,
|
||||
) ==
|
||||
false ||
|
||||
!hideWordCardContent) {
|
||||
TtsController.tryToSpeak(
|
||||
token.text.content,
|
||||
targetID: null,
|
||||
langCode: pangeaMessageEvent.messageDisplayLangCode,
|
||||
);
|
||||
}
|
||||
|
||||
// /// we don't want to associate the audio with the text in this mode
|
||||
// if (practiceSelection?.hasActiveActivityByToken(
|
||||
// ActivityTypeEnum.wordFocusListening,
|
||||
// token,
|
||||
// ) ==
|
||||
// false ||
|
||||
// !hideWordCardContent) {
|
||||
// TtsController.tryToSpeak(
|
||||
// token.text.content,
|
||||
// targetID: null,
|
||||
// langCode: pangeaMessageEvent.messageDisplayLangCode,
|
||||
// );
|
||||
// }
|
||||
updateSelectedSpan(token.text);
|
||||
}
|
||||
|
||||
void onSelectNewToken(PangeaToken token) {
|
||||
void _onSelectNewToken(PangeaToken token) {
|
||||
if (!isNewToken(token)) return;
|
||||
MatrixState.pangeaController.putAnalytics.setState(
|
||||
AnalyticsStream(
|
||||
|
|
@ -569,7 +371,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
onSelect: (emoji) async {
|
||||
final resp = await showFutureLoadingDialog(
|
||||
context: context,
|
||||
future: () => setTokenEmoji(token, emoji),
|
||||
future: () => _setTokenEmoji(token, emoji),
|
||||
);
|
||||
if (mounted && !resp.isError) {
|
||||
MatrixState.pAnyState.closeOverlay(
|
||||
|
|
@ -591,7 +393,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> setTokenEmoji(PangeaToken token, String emoji) async {
|
||||
Future<void> _setTokenEmoji(PangeaToken token, String emoji) async {
|
||||
await token.setEmoji([emoji]);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
|
@ -599,9 +401,6 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
String tokenEmojiPopupKey(PangeaToken token) =>
|
||||
"${token.uniqueId}_${event.eventId}_emoji_button";
|
||||
|
||||
/////////////////////////////////////
|
||||
/// Build
|
||||
/////////////////////////////////////
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MessageSelectionPositioner(
|
||||
|
|
|
|||
|
|
@ -55,8 +55,8 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
|
|||
|
||||
ScrollController? scrollController;
|
||||
|
||||
bool finishedTransition = false;
|
||||
bool _startedTransition = false;
|
||||
ValueNotifier<bool> finishedTransition = ValueNotifier(false);
|
||||
final ValueNotifier<bool> _startedTransition = ValueNotifier(false);
|
||||
|
||||
ReadingAssistanceMode readingAssistanceMode =
|
||||
ReadingAssistanceMode.selectMode;
|
||||
|
|
@ -355,19 +355,11 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
|
|||
}
|
||||
|
||||
void onStartedTransition() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_startedTransition = true;
|
||||
});
|
||||
}
|
||||
if (mounted) _startedTransition.value = true;
|
||||
}
|
||||
|
||||
void onFinishedTransition() {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
finishedTransition = true;
|
||||
});
|
||||
}
|
||||
if (mounted) finishedTransition.value = true;
|
||||
}
|
||||
|
||||
void launchPractice(ReadingAssistanceMode mode) {
|
||||
|
|
@ -402,21 +394,30 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
|
|||
alignment:
|
||||
ownMessage ? Alignment.centerRight : Alignment.centerLeft,
|
||||
children: [
|
||||
if (!_startedTransition) ...[
|
||||
OverMessageOverlay(controller: this),
|
||||
if (shouldScroll)
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: _wordCardLeftOffset,
|
||||
right: messageRightOffset,
|
||||
child: WordCardSwitcher(controller: this),
|
||||
),
|
||||
],
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _startedTransition,
|
||||
builder: (context, started, __) {
|
||||
return !started
|
||||
? OverMessageOverlay(controller: this)
|
||||
: const SizedBox();
|
||||
},
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _startedTransition,
|
||||
builder: (context, started, __) {
|
||||
return !started && shouldScroll
|
||||
? Positioned(
|
||||
top: 0,
|
||||
left: _wordCardLeftOffset,
|
||||
right: messageRightOffset,
|
||||
child: WordCardSwitcher(controller: this),
|
||||
)
|
||||
: const SizedBox();
|
||||
},
|
||||
),
|
||||
if (readingAssistanceMode ==
|
||||
ReadingAssistanceMode.practiceMode) ...[
|
||||
CenteredMessage(
|
||||
targetId:
|
||||
"overlay_center_message_${widget.event.eventId}",
|
||||
controller: this,
|
||||
),
|
||||
PracticeModeTransitionAnimation(
|
||||
|
|
@ -429,7 +430,9 @@ class MessageSelectionPositionerState extends State<MessageSelectionPositioner>
|
|||
right: 0,
|
||||
bottom: 20,
|
||||
child: ReadingAssistanceInputBar(
|
||||
widget.overlayController,
|
||||
widget.overlayController.practiceController,
|
||||
maxWidth: widget.overlayController.maxWidth,
|
||||
selectedToken: widget.overlayController.selectedToken,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -68,11 +68,8 @@ class OverMessageOverlay extends StatelessWidget {
|
|||
hasReactions: controller.hasReactions,
|
||||
isTransitionAnimation: true,
|
||||
readingAssistanceMode: controller.readingAssistanceMode,
|
||||
overlayKey: MatrixState.pAnyState
|
||||
.layerLinkAndKey(
|
||||
'overlay_message_${controller.widget.event.eventId}',
|
||||
)
|
||||
.key,
|
||||
overlayKey:
|
||||
'overlay_message_${controller.widget.event.eventId}',
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
|||
|
|
@ -29,10 +29,11 @@ class OverlayCenterContent extends StatelessWidget {
|
|||
final bool isTransitionAnimation;
|
||||
final ReadingAssistanceMode? readingAssistanceMode;
|
||||
|
||||
final LabeledGlobalKey? overlayKey;
|
||||
final String overlayKey;
|
||||
|
||||
const OverlayCenterContent({
|
||||
required this.event,
|
||||
required this.overlayKey,
|
||||
this.messageHeight,
|
||||
this.messageWidth,
|
||||
required this.overlayController,
|
||||
|
|
@ -44,7 +45,6 @@ class OverlayCenterContent extends StatelessWidget {
|
|||
this.sizeAnimation,
|
||||
this.isTransitionAnimation = false,
|
||||
this.readingAssistanceMode,
|
||||
this.overlayKey,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -69,7 +69,7 @@ class OverlayCenterContent extends StatelessWidget {
|
|||
MeasureRenderBox(
|
||||
onChange: onChangeMessageSize,
|
||||
child: OverlayMessage(
|
||||
key: overlayKey,
|
||||
overlayKey: overlayKey,
|
||||
event,
|
||||
controller: chatController,
|
||||
overlayController: overlayController,
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class OverlayMessage extends StatelessWidget {
|
|||
|
||||
final bool isTransitionAnimation;
|
||||
final ReadingAssistanceMode? readingAssistanceMode;
|
||||
final String overlayKey;
|
||||
|
||||
const OverlayMessage(
|
||||
this.event, {
|
||||
|
|
@ -49,6 +50,7 @@ class OverlayMessage extends StatelessWidget {
|
|||
required this.timeline,
|
||||
required this.messageWidth,
|
||||
required this.messageHeight,
|
||||
required this.overlayKey,
|
||||
this.nextEvent,
|
||||
this.previousEvent,
|
||||
this.sizeAnimation,
|
||||
|
|
@ -274,6 +276,7 @@ class OverlayMessage extends StatelessWidget {
|
|||
);
|
||||
|
||||
return Material(
|
||||
key: MatrixState.pAnyState.layerLinkAndKey(overlayKey).key,
|
||||
type: MaterialType.transparency,
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
|
|
|
|||
|
|
@ -1,260 +0,0 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/common/widgets/choice_array.dart';
|
||||
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_record.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_audio_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/practice_activity_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/word_audio_button.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
/// The multiple choice activity view
|
||||
class MultipleChoiceActivity extends StatefulWidget {
|
||||
final PracticeActivityCardState practiceCardController;
|
||||
final PracticeActivityModel currentActivity;
|
||||
final VoidCallback? onError;
|
||||
final MessageOverlayController overlayController;
|
||||
final String? initialSelectedChoice;
|
||||
final bool clearResponsesOnUpdate;
|
||||
|
||||
const MultipleChoiceActivity({
|
||||
super.key,
|
||||
required this.practiceCardController,
|
||||
required this.currentActivity,
|
||||
required this.overlayController,
|
||||
this.initialSelectedChoice,
|
||||
this.clearResponsesOnUpdate = false,
|
||||
this.onError,
|
||||
});
|
||||
|
||||
@override
|
||||
MultipleChoiceActivityState createState() => MultipleChoiceActivityState();
|
||||
}
|
||||
|
||||
class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
|
||||
int? selectedChoiceIndex;
|
||||
|
||||
PracticeRecord? get currentRecordModel =>
|
||||
widget.practiceCardController.currentCompletionRecord;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.currentActivity.multipleChoiceContent == null) {
|
||||
throw Exception(
|
||||
"MultipleChoiceActivityState: currentActivity.multipleChoiceContent is null",
|
||||
);
|
||||
}
|
||||
if (widget.initialSelectedChoice != null) {
|
||||
currentRecordModel?.addResponse(
|
||||
target: widget.currentActivity.practiceTarget,
|
||||
cId: widget.currentActivity.morphFeature == null
|
||||
? widget.currentActivity.targetTokens.first.vocabConstructID
|
||||
: widget.currentActivity.targetTokens.first
|
||||
.morphIdByFeature(widget.currentActivity.morphFeature!)!,
|
||||
text: widget.initialSelectedChoice,
|
||||
score: 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MultipleChoiceActivity oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.currentActivity.hashCode != oldWidget.currentActivity.hashCode) {
|
||||
setState(() => selectedChoiceIndex = null);
|
||||
}
|
||||
}
|
||||
|
||||
void updateChoice(String value, int index) {
|
||||
final bool isCorrect =
|
||||
widget.currentActivity.multipleChoiceContent!.isCorrect(value, index);
|
||||
|
||||
if (currentRecordModel?.hasTextResponse(value) ?? false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (widget.clearResponsesOnUpdate) {
|
||||
currentRecordModel?.clearResponses();
|
||||
}
|
||||
|
||||
currentRecordModel?.addResponse(
|
||||
target: widget.currentActivity.practiceTarget,
|
||||
cId: widget.currentActivity.morphFeature == null
|
||||
? widget.currentActivity.targetTokens.first.vocabConstructID
|
||||
: widget.currentActivity.targetTokens.first
|
||||
.morphIdByFeature(widget.currentActivity.morphFeature!)!,
|
||||
text: value,
|
||||
score: isCorrect ? 1 : 0,
|
||||
);
|
||||
|
||||
if (currentRecordModel == null ||
|
||||
currentRecordModel?.latestResponse == null ||
|
||||
widget.practiceCardController.currentActivity == null) {
|
||||
ErrorHandler.logError(
|
||||
e: "Missing necessary information to send analytics in multiple choice activity",
|
||||
data: {
|
||||
"currentRecordModel": currentRecordModel,
|
||||
"latestResponse": currentRecordModel?.latestResponse,
|
||||
"currentActivity": widget.practiceCardController.currentActivity,
|
||||
},
|
||||
);
|
||||
debugger(when: kDebugMode);
|
||||
return;
|
||||
}
|
||||
|
||||
MatrixState.pangeaController.putAnalytics.setState(
|
||||
AnalyticsStream(
|
||||
// note - this maybe should be the activity event id
|
||||
eventId: widget.overlayController.event.eventId,
|
||||
roomId: widget.overlayController.event.room.id,
|
||||
constructs: currentRecordModel!.latestResponse!.toUses(
|
||||
widget.practiceCardController.currentActivity!,
|
||||
widget.practiceCardController.metadata,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// If the selected choice is correct, send the record
|
||||
if (widget.currentActivity.multipleChoiceContent!.isCorrect(value, index)) {
|
||||
// If the activity is an emoji activity, set the emoji value
|
||||
|
||||
// TODO: this widget is deprecated for use with emoji activities
|
||||
// if (widget.currentActivity.activityType == ActivityTypeEnum.emoji) {
|
||||
// if (widget.currentActivity.targetTokens?.length != 1) {
|
||||
// debugger(when: kDebugMode);
|
||||
// } else {
|
||||
// widget.currentActivity.targetTokens!.first.setEmoji(value);
|
||||
// }
|
||||
// }
|
||||
|
||||
// The next entry in the analytics stream should be from the above putAnalytics.setState.
|
||||
// So we can wait for the stream to update before calling onActivityFinish.
|
||||
final streamFuture = MatrixState
|
||||
.pangeaController.getAnalytics.analyticsStream.stream.first;
|
||||
streamFuture.then((_) {
|
||||
widget.practiceCardController.onActivityFinish();
|
||||
});
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(
|
||||
() => selectedChoiceIndex = index,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
List<Choice> choices(BuildContext context) {
|
||||
final activity = widget.currentActivity.multipleChoiceContent;
|
||||
final List<Choice> choices = [];
|
||||
for (int i = 0; i < activity!.choices.length; i++) {
|
||||
final String value = activity.choices[i];
|
||||
final color = currentRecordModel?.hasTextResponse(value) ?? false
|
||||
? activity.choiceColor(i)
|
||||
: null;
|
||||
final isGold = activity.isCorrect(value, i);
|
||||
choices.add(
|
||||
Choice(
|
||||
text: value,
|
||||
color: color,
|
||||
isGold: isGold,
|
||||
),
|
||||
);
|
||||
}
|
||||
return choices;
|
||||
}
|
||||
|
||||
String _getDisplayCopy(String value) {
|
||||
if (widget.currentActivity.activityType != ActivityTypeEnum.morphId) {
|
||||
return value;
|
||||
}
|
||||
final morphFeature = widget
|
||||
.practiceCardController.widget.targetTokensAndActivityType.morphFeature;
|
||||
if (morphFeature == null) return value;
|
||||
|
||||
return getGrammarCopy(
|
||||
category: morphFeature.name,
|
||||
lemma: value,
|
||||
context: context,
|
||||
) ??
|
||||
value;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PracticeActivityModel practiceActivity = widget.currentActivity;
|
||||
final question = practiceActivity.multipleChoiceContent!.question;
|
||||
|
||||
// if (ActivityTypeEnum.emoji == practiceActivity.activityType) {
|
||||
// return WordEmojiChoiceRow(
|
||||
// activity: practiceActivity,
|
||||
// selectedChoiceIndex: selectedChoiceIndex,
|
||||
// onTap: updateChoice,
|
||||
// );
|
||||
// }
|
||||
|
||||
final content = Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (question.isNotEmpty)
|
||||
Text(
|
||||
question,
|
||||
textAlign: TextAlign.center,
|
||||
style: AppConfig.messageTextStyle(
|
||||
widget.overlayController.event,
|
||||
Theme.of(context).colorScheme.primary,
|
||||
).merge(const TextStyle(fontStyle: FontStyle.italic)),
|
||||
),
|
||||
if (question.isNotEmpty) const SizedBox(height: 8.0),
|
||||
const SizedBox(height: 8),
|
||||
if (practiceActivity.activityType ==
|
||||
ActivityTypeEnum.wordFocusListening)
|
||||
WordAudioButton(
|
||||
text: practiceActivity.multipleChoiceContent!.answers.first,
|
||||
uniqueID:
|
||||
"audio-activity-${widget.overlayController.event.eventId}",
|
||||
langCode: widget
|
||||
.overlayController.pangeaMessageEvent.messageDisplayLangCode,
|
||||
),
|
||||
if (practiceActivity.activityType ==
|
||||
ActivityTypeEnum.hiddenWordListening)
|
||||
MessageAudioCard(
|
||||
overlayController: widget.overlayController,
|
||||
onError: widget.onError,
|
||||
),
|
||||
ChoicesArray(
|
||||
isLoading: false,
|
||||
onPressed: updateChoice,
|
||||
selectedChoiceIndex: selectedChoiceIndex,
|
||||
choices: choices(context),
|
||||
id: currentRecordModel?.hashCode.toString(),
|
||||
enableAudio: practiceActivity.activityType.includeTTSOnClick,
|
||||
langCode:
|
||||
MatrixState.pangeaController.languageController.activeL2Code(),
|
||||
getDisplayCopy: _getDisplayCopy,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: AppConfig.toolbarMinWidth,
|
||||
maxHeight: AppConfig.toolbarMaxHeight,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: content,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +1,18 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/async_state.dart';
|
||||
import 'package:fluffychat/pangea/common/widgets/card_error_widget.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_record.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/controllers/tts_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/event_wrappers/practice_activity_event.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/message_morph_choice.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/practice_match_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_activity/multiple_choice_activity.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/practice_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_content_loading_indicator.dart';
|
||||
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
/// The wrapper for practice activity content.
|
||||
|
|
@ -30,12 +20,16 @@ import 'package:fluffychat/widgets/matrix.dart';
|
|||
/// their navigation, and the management of completion records
|
||||
class PracticeActivityCard extends StatefulWidget {
|
||||
final PracticeTarget targetTokensAndActivityType;
|
||||
final MessageOverlayController overlayController;
|
||||
final PracticeController controller;
|
||||
final PangeaToken? selectedToken;
|
||||
final double maxWidth;
|
||||
|
||||
const PracticeActivityCard({
|
||||
super.key,
|
||||
required this.targetTokensAndActivityType,
|
||||
required this.overlayController,
|
||||
required this.controller,
|
||||
required this.selectedToken,
|
||||
required this.maxWidth,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -43,23 +37,8 @@ class PracticeActivityCard extends StatefulWidget {
|
|||
}
|
||||
|
||||
class PracticeActivityCardState extends State<PracticeActivityCard> {
|
||||
bool fetchingActivity = true;
|
||||
bool savoringTheJoy = false;
|
||||
|
||||
Completer<PracticeActivityEvent?>? currentActivityCompleter;
|
||||
|
||||
PracticeRepo practiceGenerationController = PracticeRepo();
|
||||
|
||||
PangeaController get pangeaController => MatrixState.pangeaController;
|
||||
String? _error;
|
||||
|
||||
PracticeActivityModel? get currentActivity =>
|
||||
widget.overlayController.activity;
|
||||
|
||||
PracticeRecord? get currentCompletionRecord => currentActivity?.record;
|
||||
|
||||
PangeaMessageEvent? get pangeaMessageEvent =>
|
||||
widget.overlayController.pangeaMessageEvent;
|
||||
final ValueNotifier<AsyncState<PracticeActivityModel>> _activityState =
|
||||
ValueNotifier(const AsyncState.loading());
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -80,237 +59,61 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
practiceGenerationController.dispose();
|
||||
_activityState.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _updateFetchingActivity(bool value) {
|
||||
if (fetchingActivity == value) return;
|
||||
if (mounted) setState(() => fetchingActivity = value);
|
||||
}
|
||||
|
||||
Future<void> _fetchActivity({
|
||||
ActivityQualityFeedback? activityFeedback,
|
||||
}) async {
|
||||
_error = null;
|
||||
if (!mounted ||
|
||||
!pangeaController.languageController.languagesSet ||
|
||||
widget.overlayController.practiceSelection == null) {
|
||||
_updateFetchingActivity(false);
|
||||
Future<void> _fetchActivity() async {
|
||||
_activityState.value = const AsyncState.loading();
|
||||
if (!MatrixState.pangeaController.languageController.languagesSet) {
|
||||
_activityState.value = const AsyncState.error("Error fetching activity");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
_updateFetchingActivity(true);
|
||||
final activity = await _fetchActivityModel(
|
||||
activityFeedback: activityFeedback,
|
||||
);
|
||||
|
||||
if (activity == null) {
|
||||
widget.overlayController.exitPracticeFlow();
|
||||
return;
|
||||
}
|
||||
|
||||
widget.overlayController
|
||||
.setState(() => widget.overlayController.activity = activity);
|
||||
} catch (e, s) {
|
||||
debugPrint(
|
||||
"Error fetching activity: $e",
|
||||
);
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {
|
||||
'activity': currentActivity?.toJson(),
|
||||
'record': currentCompletionRecord?.toJson(),
|
||||
'targetTokens': widget.targetTokensAndActivityType.tokens
|
||||
.map((token) => token.toJson())
|
||||
.toList(),
|
||||
'activityType': widget.targetTokensAndActivityType.activityType,
|
||||
'morphFeature': widget.targetTokensAndActivityType.morphFeature,
|
||||
},
|
||||
);
|
||||
debugger(when: kDebugMode);
|
||||
_error = e.toString();
|
||||
} finally {
|
||||
_updateFetchingActivity(false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<PracticeActivityModel?> _fetchActivityModel({
|
||||
ActivityQualityFeedback? activityFeedback,
|
||||
}) async {
|
||||
debugPrint(
|
||||
"fetching activity model of type: ${widget.targetTokensAndActivityType.activityType}",
|
||||
);
|
||||
if (pangeaMessageEvent == null) return null;
|
||||
// check if we already have an activity matching the specs
|
||||
final tokens = widget.targetTokensAndActivityType.tokens;
|
||||
final type = widget.targetTokensAndActivityType.activityType;
|
||||
final morph = widget.targetTokensAndActivityType.morphFeature;
|
||||
|
||||
// final existingActivity =
|
||||
// widget.pangeaMessageEvent.practiceActivities.firstWhereOrNull(
|
||||
// (activity) =>
|
||||
// activity.practiceActivity.activityType == type &&
|
||||
// const ListEquality()
|
||||
// .equals(widget.targetTokensAndActivityType.tokens, tokens) &&
|
||||
// activity.practiceActivity.morphFeature == morph,
|
||||
// );
|
||||
|
||||
// if (existingActivity != null) {
|
||||
// currentActivityCompleter = Completer();
|
||||
// currentActivityCompleter!.complete(existingActivity);
|
||||
// existingActivity.practiceActivity.targetTokens = tokens;
|
||||
// return existingActivity.practiceActivity;
|
||||
// }
|
||||
|
||||
final req = MessageActivityRequest(
|
||||
userL1: MatrixState.pangeaController.languageController.userL1!.langCode,
|
||||
userL2: MatrixState.pangeaController.languageController.userL2!.langCode,
|
||||
messageText: pangeaMessageEvent!.messageDisplayText,
|
||||
messageTokens:
|
||||
pangeaMessageEvent?.messageDisplayRepresentation?.tokens ?? [],
|
||||
activityQualityFeedback: activityFeedback,
|
||||
targetTokens: tokens,
|
||||
targetType: type,
|
||||
targetMorphFeature: morph,
|
||||
final result = await widget.controller.fetchActivityModel(
|
||||
widget.targetTokensAndActivityType,
|
||||
);
|
||||
|
||||
final PracticeActivityModelResponse activityResponse =
|
||||
await practiceGenerationController.getPracticeActivity(
|
||||
req,
|
||||
pangeaMessageEvent,
|
||||
context,
|
||||
);
|
||||
|
||||
if (activityResponse.activity == null) return null;
|
||||
|
||||
currentActivityCompleter = activityResponse.eventCompleter;
|
||||
activityResponse.activity!.targetTokens = tokens;
|
||||
return activityResponse.activity;
|
||||
}
|
||||
|
||||
ConstructUseMetaData get metadata => ConstructUseMetaData(
|
||||
eventId: widget.overlayController.event.eventId,
|
||||
roomId: widget.overlayController.event.room.id,
|
||||
timeStamp: DateTime.now(),
|
||||
);
|
||||
|
||||
final Duration _savorTheJoyDuration = const Duration(seconds: 1);
|
||||
|
||||
Future<void> _savorTheJoy() async {
|
||||
try {
|
||||
if (mounted) setState(() => savoringTheJoy = true);
|
||||
await Future.delayed(_savorTheJoyDuration);
|
||||
if (mounted) setState(() => savoringTheJoy = false);
|
||||
} catch (e, s) {
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
m: 'Failed to savor the joy',
|
||||
data: {
|
||||
'activity': currentActivity,
|
||||
'record': currentCompletionRecord,
|
||||
},
|
||||
if (result.isValue) {
|
||||
_activityState.value = AsyncState.loaded(result.result!);
|
||||
} else {
|
||||
_activityState.value = AsyncState.error(
|
||||
"Error fetching activity: ${result.asError}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called when the user finishes an activity.
|
||||
/// Saves the completion record and sends it to the server.
|
||||
/// Fetches a new activity if there are any left to complete.
|
||||
/// Exits the practice flow if there are no more activities.
|
||||
void onActivityFinish() async {
|
||||
try {
|
||||
if (currentCompletionRecord == null || currentActivity == null) {
|
||||
debugger(when: kDebugMode);
|
||||
return;
|
||||
}
|
||||
|
||||
await _savorTheJoy();
|
||||
|
||||
// wait for savor the joy before popping from the activity queue
|
||||
// to keep the completed activity on screen for a moment
|
||||
widget.overlayController
|
||||
.onActivityFinish(currentActivity!.activityType, null);
|
||||
|
||||
TtsController.stop();
|
||||
} catch (e, s) {
|
||||
_onError();
|
||||
debugger(when: kDebugMode);
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {
|
||||
'activity': currentActivity,
|
||||
'record': currentCompletionRecord,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _onError() {
|
||||
// widget.overlayController.practiceSelection?.revealAllTokens();
|
||||
// widget.overlayController.activity = null;
|
||||
// widget.overlayController.exitPracticeFlow();
|
||||
}
|
||||
|
||||
// /// The widget that displays the current activity.
|
||||
// /// If there is no current activity, the widget returns a sizedbox with a height of 80.
|
||||
// /// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity.
|
||||
// /// If the activity type is unknown, the widget logs an error and returns a text widget with an error message.
|
||||
Widget? get activityWidget {
|
||||
if (currentActivity == null) {
|
||||
return null;
|
||||
}
|
||||
if (currentActivity!.multipleChoiceContent != null) {
|
||||
if (currentActivity!.activityType == ActivityTypeEnum.morphId) {
|
||||
return MessageMorphInputBarContent(
|
||||
overlayController: widget.overlayController,
|
||||
activity: currentActivity!,
|
||||
);
|
||||
}
|
||||
return MultipleChoiceActivity(
|
||||
practiceCardController: this,
|
||||
currentActivity: currentActivity!,
|
||||
onError: _onError,
|
||||
overlayController: widget.overlayController,
|
||||
initialSelectedChoice: null,
|
||||
clearResponsesOnUpdate:
|
||||
currentActivity?.activityType == ActivityTypeEnum.emoji,
|
||||
);
|
||||
}
|
||||
if (currentActivity!.matchContent != null) {
|
||||
return MatchActivityCard(
|
||||
currentActivity: currentActivity!,
|
||||
overlayController: widget.overlayController,
|
||||
);
|
||||
}
|
||||
debugger(when: kDebugMode);
|
||||
return const Text("No activity found");
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_error != null || (!fetchingActivity && currentActivity == null)) {
|
||||
debugger(when: kDebugMode);
|
||||
return CardErrorWidget(L10n.of(context).errorFetchingActivity);
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (activityWidget != null && !fetchingActivity) activityWidget!,
|
||||
// Conditionally show the darkening and progress indicator based on the loading state
|
||||
if (!savoringTheJoy && fetchingActivity) ...[
|
||||
// Circular progress indicator in the center
|
||||
const ToolbarContentLoadingIndicator(
|
||||
height: 40,
|
||||
),
|
||||
],
|
||||
],
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: _activityState,
|
||||
builder: (context, state, __) {
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
switch (state) {
|
||||
AsyncLoading() => const ToolbarContentLoadingIndicator(
|
||||
height: 40,
|
||||
),
|
||||
AsyncError() => CardErrorWidget(
|
||||
L10n.of(context).errorFetchingActivity,
|
||||
),
|
||||
AsyncLoaded() => state.value.multipleChoiceContent != null
|
||||
? MessageMorphInputBarContent(
|
||||
controller: widget.controller,
|
||||
activity: state.value,
|
||||
selectedToken: widget.selectedToken,
|
||||
maxWidth: widget.maxWidth,
|
||||
)
|
||||
: MatchActivityCard(
|
||||
currentActivity: state.value,
|
||||
controller: widget.controller,
|
||||
),
|
||||
_ => const SizedBox.shrink(),
|
||||
},
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
171
lib/pangea/toolbar/widgets/practice_controller.dart
Normal file
171
lib/pangea/toolbar/widgets/practice_controller.dart
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:async/async.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/put_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/user_set_lemma_info.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_choice.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_selection.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_selection_repo.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/reading_assistance_input_row/morph_selection.dart';
|
||||
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class PracticeController with ChangeNotifier {
|
||||
final PangeaMessageEvent pangeaMessageEvent;
|
||||
|
||||
PracticeController(this.pangeaMessageEvent);
|
||||
|
||||
PracticeActivityModel? _activity;
|
||||
|
||||
MessageMode practiceMode = MessageMode.noneSelected;
|
||||
|
||||
MorphSelection? selectedMorph;
|
||||
PracticeChoice? selectedChoice;
|
||||
|
||||
PracticeActivityModel? get activity => _activity;
|
||||
|
||||
PracticeSelection? get practiceSelection =>
|
||||
pangeaMessageEvent.messageDisplayRepresentation?.tokens != null
|
||||
? PracticeSelectionRepo.get(
|
||||
pangeaMessageEvent.eventId,
|
||||
pangeaMessageEvent.messageDisplayLangCode,
|
||||
pangeaMessageEvent.messageDisplayRepresentation!.tokens!,
|
||||
)
|
||||
: null;
|
||||
|
||||
bool get isTotallyDone =>
|
||||
isPracticeActivityDone(ActivityTypeEnum.emoji) &&
|
||||
isPracticeActivityDone(ActivityTypeEnum.wordMeaning) &&
|
||||
isPracticeActivityDone(ActivityTypeEnum.wordFocusListening) &&
|
||||
isPracticeActivityDone(ActivityTypeEnum.morphId);
|
||||
|
||||
bool isPracticeActivityDone(ActivityTypeEnum activityType) =>
|
||||
practiceSelection?.activities(activityType).every((a) => a.isComplete) ==
|
||||
true;
|
||||
|
||||
Future<Result<PracticeActivityModel>> fetchActivityModel(
|
||||
PracticeTarget target,
|
||||
) async {
|
||||
final req = MessageActivityRequest(
|
||||
userL1: MatrixState.pangeaController.languageController.userL1!.langCode,
|
||||
userL2: MatrixState.pangeaController.languageController.userL2!.langCode,
|
||||
messageText: pangeaMessageEvent.messageDisplayText,
|
||||
messageTokens:
|
||||
pangeaMessageEvent.messageDisplayRepresentation?.tokens ?? [],
|
||||
activityQualityFeedback: null,
|
||||
targetTokens: target.tokens,
|
||||
targetType: target.activityType,
|
||||
targetMorphFeature: target.morphFeature,
|
||||
);
|
||||
|
||||
final result = await PracticeRepo.getPracticeActivity(req);
|
||||
if (result.isValue) {
|
||||
_activity = result.result;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
PracticeTarget? practiceTargetForToken(PangeaToken token) {
|
||||
if (practiceMode.associatedActivityType == null) return null;
|
||||
return practiceSelection
|
||||
?.activities(practiceMode.associatedActivityType!)
|
||||
.firstWhereOrNull((a) => a.tokens.contains(token));
|
||||
}
|
||||
|
||||
void updateToolbarMode(MessageMode mode) {
|
||||
selectedChoice = null;
|
||||
practiceMode = mode;
|
||||
if (practiceMode != MessageMode.wordMorph) {
|
||||
selectedMorph = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void onChoiceSelect(PracticeChoice? choice, [bool force = false]) {
|
||||
if (_activity == null) return;
|
||||
if (selectedChoice == choice && !force) {
|
||||
selectedChoice = null;
|
||||
} else {
|
||||
selectedChoice = choice;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void onSelectMorph(MorphSelection newMorph) {
|
||||
practiceMode = MessageMode.wordMorph;
|
||||
selectedMorph = newMorph;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void onMatch(PangeaToken token, PracticeChoice choice) {
|
||||
if (_activity == null) return;
|
||||
|
||||
final isCorrect = _activity!.activityType == ActivityTypeEnum.morphId
|
||||
? _activity!.onMultipleChoiceSelect(token, choice)
|
||||
: _activity!.onMatch(token, choice);
|
||||
|
||||
// we don't take off points for incorrect emoji matches
|
||||
if (_activity!.activityType != ActivityTypeEnum.emoji || isCorrect) {
|
||||
final constructUseType = _activity!.practiceTarget.record.responses.last
|
||||
.useType(_activity!.activityType);
|
||||
|
||||
MatrixState.pangeaController.putAnalytics.setState(
|
||||
AnalyticsStream(
|
||||
eventId: pangeaMessageEvent.eventId,
|
||||
roomId: pangeaMessageEvent.room.id,
|
||||
constructs: [
|
||||
OneConstructUse(
|
||||
useType: constructUseType,
|
||||
lemma: token.lemma.text,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
metadata: ConstructUseMetaData(
|
||||
roomId: pangeaMessageEvent.room.id,
|
||||
timeStamp: DateTime.now(),
|
||||
eventId: pangeaMessageEvent.eventId,
|
||||
),
|
||||
category: token.pos,
|
||||
// in the case of a wrong answer, the cId doesn't match the token
|
||||
form: token.text.content,
|
||||
xp: constructUseType.pointValue,
|
||||
),
|
||||
],
|
||||
targetID:
|
||||
"message-token-${token.text.uniqueKey}-${pangeaMessageEvent.eventId}",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (isCorrect) {
|
||||
if (_activity!.activityType == ActivityTypeEnum.emoji) {
|
||||
choice.form.cId.setEmojiWithXP(
|
||||
emoji: choice.choiceContent,
|
||||
isFromCorrectAnswer: true,
|
||||
eventId: pangeaMessageEvent.eventId,
|
||||
roomId: pangeaMessageEvent.room.id,
|
||||
);
|
||||
}
|
||||
|
||||
if (_activity!.activityType == ActivityTypeEnum.wordMeaning) {
|
||||
choice.form.cId.setUserLemmaInfo(
|
||||
UserSetLemmaInfo(meaning: choice.choiceContent),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/toolbar_button.dart';
|
||||
|
||||
class PracticeModeButtons extends StatelessWidget {
|
||||
final MessageOverlayController overlayController;
|
||||
|
||||
const PracticeModeButtons({
|
||||
required this.overlayController,
|
||||
super.key,
|
||||
});
|
||||
|
||||
static const double iconWidth = 36.0;
|
||||
static const double buttonSize = 40.0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 4.0,
|
||||
children: [
|
||||
Container(
|
||||
width: buttonSize + 4,
|
||||
height: buttonSize + 4,
|
||||
alignment: Alignment.center,
|
||||
child: ToolbarButton(
|
||||
mode: MessageMode.listening,
|
||||
overlayController: overlayController,
|
||||
buttonSize: buttonSize,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: buttonSize + 4,
|
||||
height: buttonSize + 4,
|
||||
alignment: Alignment.center,
|
||||
child: ToolbarButton(
|
||||
mode: MessageMode.wordMorph,
|
||||
overlayController: overlayController,
|
||||
buttonSize: buttonSize,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: buttonSize + 4,
|
||||
height: buttonSize + 4,
|
||||
alignment: Alignment.center,
|
||||
child: ToolbarButton(
|
||||
mode: MessageMode.wordMeaning,
|
||||
overlayController: overlayController,
|
||||
buttonSize: buttonSize,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: buttonSize + 4,
|
||||
height: buttonSize + 4,
|
||||
alignment: Alignment.center,
|
||||
child: ToolbarButton(
|
||||
mode: MessageMode.wordEmoji,
|
||||
overlayController: overlayController,
|
||||
buttonSize: buttonSize,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4.0),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -27,8 +27,6 @@ class PracticeModeTransitionAnimationState
|
|||
Animation<Offset>? _offsetAnimation;
|
||||
Animation<Size>? _sizeAnimation;
|
||||
|
||||
bool _finishedAnimation = false;
|
||||
|
||||
RenderBox? get _centerMessageRenderBox {
|
||||
try {
|
||||
return MatrixState.pAnyState.getRenderBox(widget.targetId);
|
||||
|
|
@ -72,7 +70,6 @@ class PracticeModeTransitionAnimationState
|
|||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.controller.transitionAnimationDuration,
|
||||
// duration: const Duration(seconds: 3),
|
||||
);
|
||||
|
||||
_offsetAnimation = Tween<Offset>(
|
||||
|
|
@ -101,13 +98,10 @@ class PracticeModeTransitionAnimationState
|
|||
);
|
||||
|
||||
widget.controller.onStartedTransition();
|
||||
setState(() {});
|
||||
|
||||
_animationController!.forward().then((_) {
|
||||
widget.controller.onFinishedTransition();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_finishedAnimation = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
@ -120,81 +114,94 @@ class PracticeModeTransitionAnimationState
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_offsetAnimation == null || _finishedAnimation) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _offsetAnimation!,
|
||||
builder: (context, child) {
|
||||
return Positioned(
|
||||
top: _offsetAnimation!.value.dy,
|
||||
left:
|
||||
widget.controller.ownMessage ? null : _offsetAnimation!.value.dx,
|
||||
right:
|
||||
widget.controller.ownMessage ? _offsetAnimation!.value.dx : null,
|
||||
child: OverlayCenterContent(
|
||||
event: widget.controller.widget.event,
|
||||
overlayController: widget.controller.widget.overlayController,
|
||||
chatController: widget.controller.widget.chatController,
|
||||
nextEvent: widget.controller.widget.nextEvent,
|
||||
prevEvent: widget.controller.widget.prevEvent,
|
||||
hasReactions: widget.controller.hasReactions,
|
||||
sizeAnimation: _sizeAnimation,
|
||||
readingAssistanceMode: widget.controller.readingAssistanceMode,
|
||||
),
|
||||
);
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: widget.controller.finishedTransition,
|
||||
child: _offsetAnimation == null
|
||||
? const SizedBox()
|
||||
: AnimatedBuilder(
|
||||
animation: _offsetAnimation!,
|
||||
builder: (context, child) {
|
||||
return Positioned(
|
||||
top: _offsetAnimation!.value.dy,
|
||||
left: widget.controller.ownMessage
|
||||
? null
|
||||
: _offsetAnimation!.value.dx,
|
||||
right: widget.controller.ownMessage
|
||||
? _offsetAnimation!.value.dx
|
||||
: null,
|
||||
child: OverlayCenterContent(
|
||||
event: widget.controller.widget.event,
|
||||
overlayController:
|
||||
widget.controller.widget.overlayController,
|
||||
chatController: widget.controller.widget.chatController,
|
||||
nextEvent: widget.controller.widget.nextEvent,
|
||||
prevEvent: widget.controller.widget.prevEvent,
|
||||
hasReactions: widget.controller.hasReactions,
|
||||
sizeAnimation: _sizeAnimation,
|
||||
readingAssistanceMode:
|
||||
widget.controller.readingAssistanceMode,
|
||||
overlayKey:
|
||||
"overlay_transition_message_${widget.controller.widget.event.eventId}",
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
builder: (context, finished, child) {
|
||||
if (finished || _offsetAnimation == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
return child!;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CenteredMessage extends StatelessWidget {
|
||||
final String targetId;
|
||||
final MessageSelectionPositionerState controller;
|
||||
|
||||
const CenteredMessage({
|
||||
super.key,
|
||||
required this.targetId,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: controller.finishedTransition ? 1.0 : 0.0,
|
||||
child: GestureDetector(
|
||||
onTap: controller.widget.chatController.clearSelectedEvents,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width:
|
||||
controller.mediaQuery!.size.width - controller.columnWidth,
|
||||
height: 20.0,
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: controller.finishedTransition,
|
||||
builder: (context, finished, __) {
|
||||
return Opacity(
|
||||
opacity: finished ? 1.0 : 0.0,
|
||||
child: GestureDetector(
|
||||
onTap: controller.widget.chatController.clearSelectedEvents,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: controller.mediaQuery!.size.width -
|
||||
controller.columnWidth,
|
||||
height: 20.0,
|
||||
),
|
||||
OverlayCenterContent(
|
||||
event: controller.widget.event,
|
||||
overlayController: controller.widget.overlayController,
|
||||
chatController: controller.widget.chatController,
|
||||
nextEvent: controller.widget.nextEvent,
|
||||
prevEvent: controller.widget.prevEvent,
|
||||
hasReactions: controller.hasReactions,
|
||||
overlayKey:
|
||||
"overlay_center_message_${controller.widget.event.eventId}",
|
||||
readingAssistanceMode: controller.readingAssistanceMode,
|
||||
),
|
||||
const SizedBox(
|
||||
height: AppConfig.readingAssistanceInputBarHeight + 60.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
OverlayCenterContent(
|
||||
event: controller.widget.event,
|
||||
overlayController: controller.widget.overlayController,
|
||||
chatController: controller.widget.chatController,
|
||||
nextEvent: controller.widget.nextEvent,
|
||||
prevEvent: controller.widget.prevEvent,
|
||||
hasReactions: controller.hasReactions,
|
||||
overlayKey: MatrixState.pAnyState
|
||||
.layerLinkAndKey(
|
||||
"overlay_center_message_${controller.widget.event.eventId}",
|
||||
)
|
||||
.key,
|
||||
readingAssistanceMode: controller.readingAssistanceMode,
|
||||
),
|
||||
const SizedBox(
|
||||
height: AppConfig.readingAssistanceInputBarHeight + 60.0,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,50 +1,51 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/enums/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/widgets/message_selection_overlay.dart';
|
||||
|
||||
class ToolbarButton extends StatelessWidget {
|
||||
final MessageMode mode;
|
||||
final MessageOverlayController overlayController;
|
||||
final double buttonSize;
|
||||
final VoidCallback setMode;
|
||||
|
||||
final bool isComplete;
|
||||
final bool isSelected;
|
||||
|
||||
const ToolbarButton({
|
||||
required this.mode,
|
||||
required this.overlayController,
|
||||
required this.buttonSize,
|
||||
required this.setMode,
|
||||
required this.isComplete,
|
||||
required this.isSelected,
|
||||
super.key,
|
||||
});
|
||||
|
||||
Color color(BuildContext context) => mode.iconButtonColor(
|
||||
context,
|
||||
overlayController,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: mode.tooltip(context),
|
||||
child: PressableButton(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depressed: mode == overlayController.toolbarMode,
|
||||
color: color(context),
|
||||
onPressed: () => overlayController.updateToolbarMode(mode),
|
||||
playSound: true,
|
||||
colorFactor:
|
||||
Theme.of(context).brightness == Brightness.light ? 0.55 : 0.3,
|
||||
child: AnimatedContainer(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
height: buttonSize,
|
||||
width: buttonSize,
|
||||
decoration: BoxDecoration(
|
||||
color: color(context),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
mode.icon,
|
||||
size: 20,
|
||||
final color = mode.iconButtonColor(context, isComplete);
|
||||
return Container(
|
||||
width: 44.0,
|
||||
height: 44.0,
|
||||
alignment: Alignment.center,
|
||||
child: Tooltip(
|
||||
message: mode.tooltip(context),
|
||||
child: PressableButton(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
depressed: isSelected,
|
||||
color: color,
|
||||
onPressed: setMode,
|
||||
playSound: true,
|
||||
colorFactor:
|
||||
Theme.of(context).brightness == Brightness.light ? 0.55 : 0.3,
|
||||
child: Container(
|
||||
height: 40.0,
|
||||
width: 40.0,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
mode.icon,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue