fix example messages for grammar activities, make practice activity model a sealed class

This commit is contained in:
ggurdin 2026-01-15 09:59:28 -05:00
parent b698e2e84f
commit 326e5c3241
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
18 changed files with 486 additions and 328 deletions

View file

@ -10,9 +10,12 @@ import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dar
class ExampleMessageUtil {
static Future<List<InlineSpan>?> getExampleMessage(
ConstructUses construct,
Client client,
) async {
Client client, {
String? form,
}) async {
for (final use in construct.cappedUses) {
if (form != null && use.form != form) continue;
final event = await client.getEventByConstructUse(use);
if (event == null) continue;

View file

@ -10,8 +10,6 @@ import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart';
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.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/example_message_util.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.dart';
@ -64,13 +62,15 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
with AnalyticsUpdater {
late final SessionLoader _sessionLoader;
final ValueNotifier<AsyncState<PracticeActivityModel>> activityState =
ValueNotifier(const AsyncState.idle());
final ValueNotifier<AsyncState<MultipleChoicePracticeActivityModel>>
activityState = ValueNotifier(const AsyncState.idle());
final Queue<MapEntry<String, Completer<PracticeActivityModel>>> _queue =
Queue();
final Queue<
MapEntry<PracticeTarget,
Completer<MultipleChoicePracticeActivityModel>>> _queue = Queue();
final ValueNotifier<String?> activityText = ValueNotifier<String?>(null);
final ValueNotifier<PracticeTarget?> activityTarget =
ValueNotifier<PracticeTarget?>(null);
final ValueNotifier<double> progressNotifier = ValueNotifier<double>(0.0);
@ -99,14 +99,16 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
}
_sessionLoader.dispose();
activityState.dispose();
activityText.dispose();
activityTarget.dispose();
progressNotifier.dispose();
super.dispose();
}
PracticeActivityModel? get _currentActivity =>
activityState.value is AsyncLoaded<PracticeActivityModel>
? (activityState.value as AsyncLoaded<PracticeActivityModel>).value
MultipleChoicePracticeActivityModel? get _currentActivity =>
activityState.value is AsyncLoaded<MultipleChoicePracticeActivityModel>
? (activityState.value
as AsyncLoaded<MultipleChoicePracticeActivityModel>)
.value
: null;
bool get _isComplete => _sessionLoader.value?.isComplete ?? false;
@ -177,7 +179,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
void _resetActivityState() {
activityState.value = const AsyncState.loading();
activityText.value = null;
activityTarget.value = null;
}
void _resetSessionState() {
@ -258,14 +260,15 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
_continuing = true;
try {
if (activityState.value is AsyncIdle<PracticeActivityModel>) {
if (activityState.value
is AsyncIdle<MultipleChoicePracticeActivityModel>) {
await _initActivityData();
} else if (_queue.isEmpty) {
await _completeSession();
} else {
activityState.value = const AsyncState.loading();
final nextActivityCompleter = _queue.removeFirst();
activityText.value = nextActivityCompleter.key;
activityTarget.value = nextActivityCompleter.key;
final activity = await nextActivityCompleter.value.future;
activityState.value = AsyncState.loaded(activity);
}
@ -289,7 +292,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
final res = await _fetchActivity(req);
if (!mounted) return;
activityText.value = req.activityText;
activityTarget.value = req.practiceTarget;
activityState.value = AsyncState.loaded(res);
} catch (e) {
if (!mounted) return;
@ -302,13 +305,14 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
Future<void> _fillActivityQueue(List<MessageActivityRequest> requests) async {
for (final request in requests) {
final completer = Completer<PracticeActivityModel>();
final completer = Completer<MultipleChoicePracticeActivityModel>();
_queue.add(
MapEntry(
request.activityText,
request.practiceTarget,
completer,
),
);
try {
final res = await _fetchActivity(request);
if (!mounted) return;
@ -321,24 +325,28 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
}
}
Future<PracticeActivityModel> _fetchActivity(
Future<MultipleChoicePracticeActivityModel> _fetchActivity(
MessageActivityRequest req,
) async {
final result = await PracticeRepo.getPracticeActivity(
req,
messageInfo: {},
);
if (result.isError) {
if (result.isError ||
result.result is! MultipleChoicePracticeActivityModel) {
throw L10n.of(context).oopsSomethingWentWrong;
}
final activityModel = result.result as MultipleChoicePracticeActivityModel;
// Prefetch lemma info for meaning activities before marking ready
if (result.result!.activityType == ActivityTypeEnum.lemmaMeaning) {
final choices = result.result!.multipleChoiceContent!.choices.toList();
await _fetchLemmaInfo(result.result!.practiceTarget, choices);
if (activityModel.activityType == ActivityTypeEnum.lemmaMeaning) {
final choices = activityModel.multipleChoiceContent.choices.toList();
await _fetchLemmaInfo(activityModel.practiceTarget, choices);
}
return result.result!;
return activityModel;
}
Future<void> _fetchLemmaInfo(
@ -378,32 +386,14 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
// Update activity record
activity.onMultipleChoiceSelect(choiceConstruct, choiceContent);
final correct = activity.multipleChoiceContent!.isCorrect(choiceContent);
// Update session model and analytics
final useType = correct
? activity.activityType.correctUse
: activity.activityType.incorrectUse;
final use = OneConstructUse(
useType: useType,
constructType: widget.type,
metadata: ConstructUseMetaData(
roomId: null,
timeStamp: DateTime.now(),
),
category: activity.useCategory,
lemma: activity.useLemma,
form: activity.useForm,
xp: useType.pointValue,
);
final use = activity.constructUse(choiceContent);
_sessionLoader.value!.submitAnswer(use);
await _analyticsService.updateService
.addAnalytics(choiceTargetId(choiceContent), [use]);
await _saveSession();
if (!correct) return;
if (!activity.multipleChoiceContent.isCorrect(choiceContent)) return;
// Display the fact that the choice was correct before loading the next activity
await Future.delayed(const Duration(milliseconds: 1000));
@ -417,11 +407,26 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
}
Future<List<InlineSpan>?> getExampleMessage(
ConstructIdentifier construct,
PracticeTarget target,
) async {
final token = target.tokens.first;
final construct = switch (widget.type) {
ConstructTypeEnum.vocab => token.vocabConstructID,
ConstructTypeEnum.morph => token.morphIdByFeature(target.morphFeature!),
};
if (construct == null) return null;
String? form;
if (widget.type == ConstructTypeEnum.morph) {
if (target.morphFeature == null) return null;
form = token.lemma.form;
}
return ExampleMessageUtil.getExampleMessage(
await _analyticsService.getConstructUse(construct),
Matrix.of(context).client,
form: form,
);
}

View file

@ -119,10 +119,10 @@ class _AnalyticsActivityView extends StatelessWidget {
Expanded(
flex: 1,
child: ValueListenableBuilder(
valueListenable: controller.activityText,
builder: (context, text, __) => text != null
valueListenable: controller.activityTarget,
builder: (context, target, __) => target != null
? Text(
text,
target.promptText(),
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.titleLarge?.copyWith(
@ -132,19 +132,19 @@ class _AnalyticsActivityView extends StatelessWidget {
: const SizedBox(),
),
),
// Expanded(
// flex: 2,
// child: Center(
// child: ValueListenableBuilder(
// valueListenable: controller.activityConstructId,
// builder: (context, constructId, __) => constructId != null
// ? _ExampleMessageWidget(
// controller.getExampleMessage(constructId),
// )
// : const SizedBox(),
// ),
// ),
// ),
Expanded(
flex: 2,
child: Center(
child: ValueListenableBuilder(
valueListenable: controller.activityTarget,
builder: (context, target, __) => target != null
? _ExampleMessageWidget(
controller.getExampleMessage(target),
)
: const SizedBox(),
),
),
),
Expanded(
flex: 6,
child: _ActivityChoicesWidget(controller),
@ -211,14 +211,15 @@ class _ActivityChoicesWidget extends StatelessWidget {
valueListenable: controller.activityState,
builder: (context, state, __) {
return switch (state) {
AsyncLoading<PracticeActivityModel>() => const Center(
AsyncLoading<MultipleChoicePracticeActivityModel>() => const Center(
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator.adaptive(),
),
),
AsyncError<PracticeActivityModel>(:final error) => Column(
AsyncError<MultipleChoicePracticeActivityModel>(:final error) =>
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
//allow try to reload activity in case of error
@ -231,11 +232,12 @@ class _ActivityChoicesWidget extends StatelessWidget {
),
],
),
AsyncLoaded<PracticeActivityModel>(:final value) => LayoutBuilder(
AsyncLoaded<MultipleChoicePracticeActivityModel>(:final value) =>
LayoutBuilder(
builder: (context, constraints) {
final choices = controller.filteredChoices(
value.practiceTarget,
value.multipleChoiceContent!,
value.multipleChoiceContent,
);
final constrainedHeight =
constraints.maxHeight.clamp(0.0, 400.0);
@ -281,7 +283,7 @@ class _ActivityChoicesWidget extends StatelessWidget {
}
class _ChoiceCard extends StatelessWidget {
final PracticeActivityModel activity;
final MultipleChoicePracticeActivityModel activity;
final String choiceId;
final String targetId;
final VoidCallback onPressed;
@ -302,7 +304,7 @@ class _ChoiceCard extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isCorrect = activity.multipleChoiceContent!.isCorrect(choiceId);
final isCorrect = activity.multipleChoiceContent.isCorrect(choiceId);
final activityType = activity.activityType;
final constructId = activity.targetTokens.first.vocabConstructID;

View file

@ -50,8 +50,7 @@ class MorphCategoryActivityGenerator {
choices.add(morphTag);
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: req.targetType,
activity: MorphCategoryPracticeActivityModel(
targetTokens: [req.targetTokens.first],
langCode: req.userL2,
morphFeature: feature,

View file

@ -15,8 +15,7 @@ class VocabAudioActivityGenerator {
choicesList.shuffle();
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: req.targetType,
activity: VocabAudioPracticeActivityModel(
targetTokens: [token],
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(

View file

@ -18,8 +18,7 @@ class VocabMeaningActivityGenerator {
final Set<String> constructIdChoices = choices.map((c) => c.string).toSet();
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: req.targetType,
activity: VocabMeaningPracticeActivityModel(
targetTokens: [token],
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(

View file

@ -3,7 +3,6 @@ import 'package:async/async.dart';
import 'package:fluffychat/pangea/constructs/construct_form.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.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/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_match.dart';
@ -65,8 +64,7 @@ class EmojiActivityGenerator {
}
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: ActivityTypeEnum.emoji,
activity: EmojiPracticeActivityModel(
targetTokens: req.targetTokens,
langCode: req.userL2,
matchContent: PracticeMatchActivity(

View file

@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.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/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
@ -22,8 +21,7 @@ class LemmaActivityGenerator {
// TODO - modify MultipleChoiceActivity flow to allow no correct answer
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: ActivityTypeEnum.lemmaId,
activity: LemmaPracticeActivityModel(
targetTokens: [token],
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(

View file

@ -4,7 +4,6 @@ import 'package:async/async.dart';
import 'package:fluffychat/pangea/constructs/construct_form.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/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_match.dart';
@ -33,8 +32,7 @@ class LemmaMeaningActivityGenerator {
);
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: ActivityTypeEnum.wordMeaning,
activity: LemmaMeaningPracticeActivityModel(
targetTokens: req.targetTokens,
langCode: req.userL2,
matchContent: PracticeMatchActivity(

View file

@ -5,6 +5,7 @@ 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_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
// includes feedback text and the bad activity model
class ActivityQualityFeedback {
@ -16,15 +17,6 @@ class ActivityQualityFeedback {
required this.badActivity,
});
factory ActivityQualityFeedback.fromJson(Map<String, dynamic> json) {
return ActivityQualityFeedback(
feedbackText: json['feedback_text'] as String,
badActivity: PracticeActivityModel.fromJson(
json['bad_activity'] as Map<String, dynamic>,
),
);
}
Map<String, dynamic> toJson() {
return {
'feedback_text': feedbackText,
@ -90,6 +82,12 @@ class MessageActivityRequest {
};
}
PracticeTarget get practiceTarget => PracticeTarget(
activityType: targetType,
tokens: targetTokens,
morphFeature: targetMorphFeature,
);
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;

View file

@ -4,7 +4,6 @@ import 'package:flutter/foundation.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';
@ -38,11 +37,10 @@ class MorphActivityGenerator {
debugger(when: kDebugMode && distractors.length < 3);
return MessageActivityResponse(
activity: PracticeActivityModel(
activity: MorphMatchPracticeActivityModel(
targetTokens: req.targetTokens,
langCode: req.userL2,
activityType: ActivityTypeEnum.morphId,
morphFeature: req.targetMorphFeature,
morphFeature: morphFeature,
multipleChoiceContent: MultipleChoiceActivity(
choices: distractors,
answers: {morphTag},

View file

@ -1,12 +1,8 @@
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.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/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
@ -16,177 +12,23 @@ 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_target.dart';
class PracticeActivityModel {
sealed class PracticeActivityModel {
final List<PangeaToken> targetTokens;
final ActivityTypeEnum activityType;
final MorphFeaturesEnum? morphFeature;
final String langCode;
final MultipleChoiceActivity? multipleChoiceContent;
final PracticeMatchActivity? matchContent;
PracticeActivityModel({
const PracticeActivityModel({
required this.targetTokens,
required this.langCode,
required this.activityType,
this.morphFeature,
this.multipleChoiceContent,
this.matchContent,
}) {
if (matchContent == null && multipleChoiceContent == null) {
debugger(when: kDebugMode);
throw ("both matchContent and multipleChoiceContent are null in PracticeActivityModel");
}
if (matchContent != null && multipleChoiceContent != null) {
debugger(when: kDebugMode);
throw ("both matchContent and multipleChoiceContent are not null in PracticeActivityModel");
}
if (activityType == ActivityTypeEnum.morphId && morphFeature == null) {
debugger(when: kDebugMode);
throw ("morphFeature is null in PracticeActivityModel");
}
}
String get useCategory {
switch (activityType.constructType) {
case ConstructTypeEnum.morph:
assert(
morphFeature != null,
"morphFeature is null in PracticeActivityModel.useCategory",
);
return morphFeature!.name;
case ConstructTypeEnum.vocab:
return targetTokens.first.pos;
}
}
String get useLemma {
switch (activityType.constructType) {
case ConstructTypeEnum.morph:
assert(
morphFeature != null,
"morphFeature is null in PracticeActivityModel.useCategory",
);
final tag = targetTokens.first.getMorphTag(morphFeature!);
if (tag == null) {
throw ("tag is null in PracticeActivityModel.useLemma");
}
return tag;
case ConstructTypeEnum.vocab:
return targetTokens.first.lemma.text;
}
}
String get useForm {
switch (activityType.constructType) {
case ConstructTypeEnum.morph:
return targetTokens.first.lemma.form;
case ConstructTypeEnum.vocab:
return targetTokens.first.lemma.text;
}
}
});
PracticeTarget get practiceTarget => PracticeTarget(
tokens: targetTokens,
activityType: activityType,
morphFeature: morphFeature,
);
bool onMultipleChoiceSelect(
ConstructIdentifier choiceConstruct,
String choice,
) {
if (multipleChoiceContent == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "in onMultipleChoiceSelect with null multipleChoiceContent",
s: StackTrace.current,
data: toJson(),
);
return false;
}
if (practiceTarget.isComplete ||
practiceTarget.record.alreadyHasMatchResponse(
choiceConstruct,
choice,
)) {
// the user has already selected this choice
// so we don't want to record it again
return false;
}
final bool isCorrect = multipleChoiceContent!.isCorrect(choice);
// 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 🐶
// the response is associated with correct word "dog", not the word "cat"
practiceTarget.record.addResponse(
cId: choiceConstruct,
target: practiceTarget,
text: choice,
score: isCorrect ? 1 : 0,
);
return isCorrect;
}
bool onMatch(
PangeaToken token,
PracticeChoice choice,
) {
// the user has already selected this choice
// so we don't want to record it again
if (practiceTarget.isComplete ||
practiceTarget.record.alreadyHasMatchResponse(
token.vocabConstructID,
choice.choiceContent,
)) {
return false;
}
bool isCorrect = false;
if (multipleChoiceContent != null) {
isCorrect = multipleChoiceContent!.answers.any(
(answer) => answer.toLowerCase() == choice.choiceContent.toLowerCase(),
);
} 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);
}
// NOTE: the response is associated with the contructId of the selected token, not the choice
// example: the user selects the word "cat" to match with the emoji 🐶
// the response is associated with incorrect word "cat", not the word "dog"
practiceTarget.record.addResponse(
cId: token.vocabConstructID,
target: practiceTarget,
text: choice.choiceContent,
score: isCorrect ? 1 : 0,
);
return isCorrect;
}
factory PracticeActivityModel.fromJson(Map<String, dynamic> json) {
// moving from multiple_choice to content as the key
// this is to make the model more generic
// here for backward compatibility
final Map<String, dynamic>? contentMap =
(json['content'] ?? json["multiple_choice"]) as Map<String, dynamic>?;
if (contentMap == null) {
Sentry.addBreadcrumb(
Breadcrumb(data: {"json": json}),
);
throw ("content is null in PracticeActivityModel.fromJson");
}
if (json['lang_code'] is! String) {
Sentry.addBreadcrumb(
Breadcrumb(data: {"json": json}),
@ -203,58 +45,370 @@ class PracticeActivityModel {
throw ("tgt_constructs is not a list in PracticeActivityModel.fromJson");
}
return PracticeActivityModel(
langCode: json['lang_code'] as String,
activityType: ActivityTypeEnum.fromString(json['activity_type']),
multipleChoiceContent: json['content'] != null
? MultipleChoiceActivity.fromJson(contentMap)
: null,
targetTokens: (json['target_tokens'] as List)
.map((e) => PangeaToken.fromJson(e as Map<String, dynamic>))
.toList(),
matchContent: json['match_content'] != null
? PracticeMatchActivity.fromJson(contentMap)
: null,
morphFeature: json['morph_feature'] != null
? MorphFeaturesEnumExtension.fromString(
json['morph_feature'] as String,
)
: null,
);
final type = ActivityTypeEnum.fromString(json['activity_type']);
final morph = json['morph_feature'] != null
? MorphFeaturesEnumExtension.fromString(
json['morph_feature'] as String,
)
: null;
final tokens = (json['target_tokens'] as List)
.map((e) => PangeaToken.fromJson(e as Map<String, dynamic>))
.toList();
final langCode = json['lang_code'] as String;
final multipleChoiceContent = json['content'] != null
? MultipleChoiceActivity.fromJson(
json['content'] as Map<String, dynamic>,
)
: null;
final matchContent = json['match_content'] != null
? PracticeMatchActivity.fromJson(
json['match_content'] as Map<String, dynamic>,
)
: null;
switch (type) {
case ActivityTypeEnum.grammarCategory:
assert(
morph != null,
"morphFeature is null in PracticeActivityModel.fromJson for grammarCategory",
);
assert(
multipleChoiceContent != null,
"multipleChoiceContent is null in PracticeActivityModel.fromJson for grammarCategory",
);
return MorphCategoryPracticeActivityModel(
langCode: langCode,
targetTokens: tokens,
morphFeature: morph!,
multipleChoiceContent: multipleChoiceContent!,
);
case ActivityTypeEnum.lemmaAudio:
assert(
multipleChoiceContent != null,
"multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaAudio",
);
return VocabAudioPracticeActivityModel(
langCode: langCode,
targetTokens: tokens,
multipleChoiceContent: multipleChoiceContent!,
);
case ActivityTypeEnum.lemmaMeaning:
assert(
multipleChoiceContent != null,
"multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaMeaning",
);
return VocabMeaningPracticeActivityModel(
langCode: langCode,
targetTokens: tokens,
multipleChoiceContent: multipleChoiceContent!,
);
case ActivityTypeEnum.emoji:
assert(
matchContent != null,
"matchContent is null in PracticeActivityModel.fromJson for emoji",
);
return EmojiPracticeActivityModel(
langCode: langCode,
targetTokens: tokens,
matchContent: matchContent!,
);
case ActivityTypeEnum.lemmaId:
assert(
multipleChoiceContent != null,
"multipleChoiceContent is null in PracticeActivityModel.fromJson for lemmaId",
);
return LemmaPracticeActivityModel(
langCode: langCode,
targetTokens: tokens,
multipleChoiceContent: multipleChoiceContent!,
);
case ActivityTypeEnum.wordMeaning:
assert(
matchContent != null,
"matchContent is null in PracticeActivityModel.fromJson for wordMeaning",
);
return LemmaMeaningPracticeActivityModel(
langCode: langCode,
targetTokens: tokens,
matchContent: matchContent!,
);
case ActivityTypeEnum.morphId:
assert(
morph != null,
"morphFeature is null in PracticeActivityModel.fromJson for morphId",
);
assert(
multipleChoiceContent != null,
"multipleChoiceContent is null in PracticeActivityModel.fromJson for morphId",
);
return MorphMatchPracticeActivityModel(
langCode: langCode,
targetTokens: tokens,
morphFeature: morph!,
multipleChoiceContent: multipleChoiceContent!,
);
case ActivityTypeEnum.wordFocusListening:
assert(
matchContent != null,
"matchContent is null in PracticeActivityModel.fromJson for wordFocusListening",
);
return WordListeningPracticeActivityModel(
langCode: langCode,
targetTokens: tokens,
matchContent: matchContent!,
);
default:
throw ("Unsupported activity type in PracticeActivityModel.fromJson: $type");
}
}
Map<String, dynamic> toJson() {
return {
'lang_code': langCode,
'activity_type': activityType.name,
'content': multipleChoiceContent?.toJson(),
'target_tokens': targetTokens.map((e) => e.toJson()).toList(),
'match_content': matchContent?.toJson(),
'morph_feature': morphFeature?.name,
};
}
}
// override operator == and hashCode
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
sealed class MultipleChoicePracticeActivityModel extends PracticeActivityModel {
final MultipleChoiceActivity multipleChoiceContent;
return other is PracticeActivityModel &&
const ListEquality().equals(other.targetTokens, targetTokens) &&
other.langCode == langCode &&
other.activityType == activityType &&
other.multipleChoiceContent == multipleChoiceContent &&
other.matchContent == matchContent &&
other.morphFeature == morphFeature;
MultipleChoicePracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.activityType,
required this.multipleChoiceContent,
});
bool onMultipleChoiceSelect(
ConstructIdentifier choiceConstruct,
String choice,
) {
if (practiceTarget.isComplete ||
practiceTarget.record.alreadyHasMatchResponse(
choiceConstruct,
choice,
)) {
// the user has already selected this choice
// so we don't want to record it again
return false;
}
final bool isCorrect = multipleChoiceContent.isCorrect(choice);
practiceTarget.record.addResponse(
cId: choiceConstruct,
target: practiceTarget,
text: choice,
score: isCorrect ? 1 : 0,
);
return isCorrect;
}
OneConstructUse constructUse(String choiceContent) {
final correct = multipleChoiceContent.isCorrect(choiceContent);
final useType =
correct ? activityType.correctUse : activityType.incorrectUse;
return OneConstructUse(
useType: useType,
constructType: ConstructTypeEnum.vocab,
metadata: ConstructUseMetaData(
roomId: null,
timeStamp: DateTime.now(),
),
category: targetTokens.first.pos,
lemma: targetTokens.first.lemma.text,
form: targetTokens.first.lemma.text,
xp: useType.pointValue,
);
}
@override
int get hashCode {
return const ListEquality().hash(targetTokens) ^
langCode.hashCode ^
activityType.hashCode ^
multipleChoiceContent.hashCode ^
matchContent.hashCode ^
morphFeature.hashCode;
Map<String, dynamic> toJson() {
final json = super.toJson();
json['content'] = multipleChoiceContent.toJson();
return json;
}
}
sealed class MatchPracticeActivityModel extends PracticeActivityModel {
final PracticeMatchActivity matchContent;
MatchPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.activityType,
required this.matchContent,
});
bool onMatch(
PangeaToken token,
PracticeChoice choice,
) {
// the user has already selected this choice
// so we don't want to record it again
if (practiceTarget.isComplete ||
practiceTarget.record.alreadyHasMatchResponse(
token.vocabConstructID,
choice.choiceContent,
)) {
return false;
}
final answers = matchContent.matchInfo[token.vocabForm];
final isCorrect = answers!.contains(choice.choiceContent);
practiceTarget.record.addResponse(
cId: token.vocabConstructID,
target: practiceTarget,
text: choice.choiceContent,
score: isCorrect ? 1 : 0,
);
return isCorrect;
}
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
json['match_content'] = matchContent.toJson();
return json;
}
}
sealed class MorphPracticeActivityModel
extends MultipleChoicePracticeActivityModel {
final MorphFeaturesEnum morphFeature;
MorphPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.activityType,
required super.multipleChoiceContent,
required this.morphFeature,
});
@override
PracticeTarget get practiceTarget => PracticeTarget(
tokens: targetTokens,
activityType: activityType,
morphFeature: morphFeature,
);
@override
Map<String, dynamic> toJson() {
final json = super.toJson();
json['morph_feature'] = morphFeature.name;
return json;
}
}
class MorphCategoryPracticeActivityModel extends MorphPracticeActivityModel {
MorphCategoryPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.morphFeature,
required super.multipleChoiceContent,
}) : super(
activityType: ActivityTypeEnum.grammarCategory,
);
@override
OneConstructUse constructUse(String choiceContent) {
final correct = multipleChoiceContent.isCorrect(choiceContent);
final useType =
correct ? activityType.correctUse : activityType.incorrectUse;
final tag = targetTokens.first.getMorphTag(morphFeature)!;
return OneConstructUse(
useType: useType,
constructType: ConstructTypeEnum.morph,
metadata: ConstructUseMetaData(
roomId: null,
timeStamp: DateTime.now(),
),
category: morphFeature.name,
lemma: tag,
form: targetTokens.first.lemma.form,
xp: useType.pointValue,
);
}
}
class MorphMatchPracticeActivityModel extends MorphPracticeActivityModel {
MorphMatchPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.morphFeature,
required super.multipleChoiceContent,
}) : super(
activityType: ActivityTypeEnum.morphId,
);
}
class VocabAudioPracticeActivityModel
extends MultipleChoicePracticeActivityModel {
VocabAudioPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.multipleChoiceContent,
}) : super(
activityType: ActivityTypeEnum.lemmaAudio,
);
}
class VocabMeaningPracticeActivityModel
extends MultipleChoicePracticeActivityModel {
VocabMeaningPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.multipleChoiceContent,
}) : super(
activityType: ActivityTypeEnum.lemmaMeaning,
);
}
class LemmaPracticeActivityModel extends MultipleChoicePracticeActivityModel {
LemmaPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.multipleChoiceContent,
}) : super(
activityType: ActivityTypeEnum.lemmaId,
);
}
class EmojiPracticeActivityModel extends MatchPracticeActivityModel {
EmojiPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.matchContent,
}) : super(
activityType: ActivityTypeEnum.emoji,
);
}
class LemmaMeaningPracticeActivityModel extends MatchPracticeActivityModel {
LemmaMeaningPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.matchContent,
}) : super(
activityType: ActivityTypeEnum.wordMeaning,
);
}
class WordListeningPracticeActivityModel extends MatchPracticeActivityModel {
WordListeningPracticeActivityModel({
required super.targetTokens,
required super.langCode,
required super.matchContent,
}) : super(
activityType: ActivityTypeEnum.wordFocusListening,
);
}

View file

@ -164,4 +164,13 @@ class PracticeTarget {
}
return false;
}
String promptText() {
switch (activityType) {
case ActivityTypeEnum.grammarCategory:
return "${tokens.first.vocabConstructID.lemma}: ${morphFeature!.name}";
default:
return tokens.first.vocabConstructID.lemma;
}
}
}

View file

@ -1,5 +1,4 @@
import 'package:fluffychat/pangea/constructs/construct_form.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';
@ -15,8 +14,7 @@ class WordFocusListeningGenerator {
}
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: ActivityTypeEnum.wordFocusListening,
activity: WordListeningPracticeActivityModel(
targetTokens: req.targetTokens,
langCode: req.userL2,
matchContent: PracticeMatchActivity(

View file

@ -30,7 +30,7 @@ const int numberOfMorphDistractors = 3;
class MessageMorphInputBarContent extends StatefulWidget {
final PracticeController controller;
final PracticeActivityModel activity;
final MorphPracticeActivityModel activity;
final PangeaToken? selectedToken;
final double maxWidth;
@ -52,7 +52,7 @@ class MessageMorphInputBarContentState
String? selectedTag;
PangeaToken get token => widget.activity.targetTokens.first;
MorphFeaturesEnum get morph => widget.activity.morphFeature!;
MorphFeaturesEnum get morph => widget.activity.morphFeature;
@override
void didUpdateWidget(covariant MessageMorphInputBarContent oldWidget) {
@ -114,7 +114,7 @@ class MessageMorphInputBarContentState
runAlignment: WrapAlignment.center,
spacing: spacing,
runSpacing: spacing,
children: widget.activity.multipleChoiceContent!.choices.mapIndexed(
children: widget.activity.multipleChoiceContent.choices.mapIndexed(
(index, choice) {
final wasCorrect =
widget.activity.practiceTarget.wasCorrectChoice(choice);
@ -137,7 +137,7 @@ class MessageMorphInputBarContentState
form: ConstructForm(
cId: widget.activity.targetTokens.first
.morphIdByFeature(
widget.activity.morphFeature!,
widget.activity.morphFeature,
)!,
form: token.text.content,
),

View file

@ -98,17 +98,20 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
AsyncError() => CardErrorWidget(
L10n.of(context).errorFetchingActivity,
),
AsyncLoaded() => state.value.multipleChoiceContent != null
? MessageMorphInputBarContent(
AsyncLoaded() => switch (state.value) {
MultipleChoicePracticeActivityModel() =>
MessageMorphInputBarContent(
controller: widget.controller,
activity: state.value,
activity: state.value as MorphPracticeActivityModel,
selectedToken: widget.selectedToken,
maxWidth: widget.maxWidth,
)
: MatchActivityCard(
currentActivity: state.value,
),
MatchPracticeActivityModel() => MatchActivityCard(
currentActivity:
state.value as MatchPracticeActivityModel,
controller: widget.controller,
),
},
_ => const SizedBox.shrink(),
},
],

View file

@ -35,8 +35,6 @@ class PracticeController with ChangeNotifier {
MorphSelection? selectedMorph;
PracticeChoice? selectedChoice;
PracticeActivityModel? get activity => _activity;
PracticeSelection? practiceSelection;
bool get isTotallyDone =>
@ -65,12 +63,11 @@ class PracticeController with ChangeNotifier {
return target == null;
}
return target == null ||
target.isCompleteByToken(
token,
_activity?.morphFeature,
) ==
true;
final morph = _activity is MorphPracticeActivityModel
? (_activity as MorphPracticeActivityModel).morphFeature
: null;
return target == null || target.isCompleteByToken(token, morph) == true;
}
bool get showChoiceShimmer {
@ -151,11 +148,13 @@ class PracticeController with ChangeNotifier {
void onMatch(PangeaToken token, PracticeChoice choice) {
if (_activity == null) return;
final isCorrect = _activity!.activityType == ActivityTypeEnum.morphId
? _activity!
.onMultipleChoiceSelect(choice.form.cId, choice.choiceContent)
: _activity!.onMatch(token, choice);
final isCorrect = switch (_activity!) {
MultipleChoicePracticeActivityModel() =>
(_activity as MultipleChoicePracticeActivityModel)
.onMultipleChoiceSelect(choice.form.cId, choice.choiceContent),
MatchPracticeActivityModel() =>
(_activity as MatchPracticeActivityModel).onMatch(token, choice),
};
final targetId =
"message-token-${token.text.uniqueKey}-${pangeaMessageEvent.eventId}";

View file

@ -16,7 +16,7 @@ import 'package:fluffychat/pangea/toolbar/message_practice/practice_controller.d
import 'package:fluffychat/pangea/toolbar/message_practice/practice_match_item.dart';
class MatchActivityCard extends StatelessWidget {
final PracticeActivityModel currentActivity;
final MatchPracticeActivityModel currentActivity;
final PracticeController controller;
const MatchActivityCard({
@ -25,8 +25,6 @@ class MatchActivityCard extends StatelessWidget {
required this.controller,
});
PracticeActivityModel get activity => currentActivity;
ActivityTypeEnum get activityType => currentActivity.activityType;
Widget choiceDisplayContent(
@ -83,7 +81,7 @@ class MatchActivityCard extends StatelessWidget {
alignment: WrapAlignment.center,
spacing: 4.0,
runSpacing: 4.0,
children: activity.matchContent!.choices.map(
children: currentActivity.matchContent.choices.map(
(PracticeChoice cf) {
final bool? wasCorrect =
currentActivity.practiceTarget.wasCorrectMatch(cf);