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:
ggurdin 2025-12-01 13:33:51 -05:00 committed by GitHub
parent cd4600501d
commit 660b92fdf1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 1303 additions and 3779 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +0,0 @@
enum ActivityDisplayInstructionsEnum { highlight, hide, nothing }
extension ActivityDisplayInstructionsEnumExt
on ActivityDisplayInstructionsEnum {
String get string => toString().split('.').last;
}

View file

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

View file

@ -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 = {};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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']) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
),
),
],

View file

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

View file

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

View file

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

View file

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

View file

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

View 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();
}
}

View file

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

View file

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

View file

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