Merge pull request #5269 from pangeachat/grammar-practice

Grammar practice
This commit is contained in:
ggurdin 2026-01-20 11:44:18 -05:00 committed by GitHub
commit f6bc57ae7e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 475 additions and 63 deletions

View file

@ -5051,5 +5051,8 @@
"practiceGrammar": "Practice grammar",
"notEnoughToPractice": "Send more messages to unlock practice",
"constructUseCorGCDesc": "Correct grammar category practice",
"constructUseIncGCDesc": "Incorrect grammar category practice"
"constructUseIncGCDesc": "Incorrect grammar category practice",
"constructUseCorGEDesc": "Correct grammar error practice",
"constructUseIncGEDesc": "Incorrect grammar error practice",
"fillInBlank": "Fill in the blank with the correct choice"
}

View file

@ -14,6 +14,7 @@ import 'package:fluffychat/pangea/analytics_data/level_up_analytics_service.dart
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.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_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_settings/analytics_settings_extension.dart';
@ -233,12 +234,14 @@ class AnalyticsDataService {
int? count,
String? roomId,
DateTime? since,
ConstructUseTypeEnum? type,
}) async {
await _ensureInitialized();
final uses = await _analyticsClientGetter.database.getUses(
count: count,
roomId: roomId,
since: since,
type: type,
);
final blocked = blockedConstructs;

View file

@ -10,6 +10,7 @@ import 'package:synchronized/synchronized.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_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_event.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
@ -197,6 +198,7 @@ class AnalyticsDatabase with DatabaseFileStorage {
int? count,
String? roomId,
DateTime? since,
ConstructUseTypeEnum? type,
}) async {
final stopwatch = Stopwatch()..start();
final results = <OneConstructUse>[];
@ -208,6 +210,9 @@ class AnalyticsDatabase with DatabaseFileStorage {
if (roomId != null && use.metadata.roomId != roomId) {
return true; // skip but continue
}
if (type != null && use.useType != type) {
return true; // skip but continue
}
results.add(use);
return count == null || results.length < count;

View file

@ -86,6 +86,10 @@ enum ConstructUseTypeEnum {
// grammar category activity
corGC,
incGC,
// grammar error activity
corGE,
incGE,
}
extension ConstructUseTypeExtension on ConstructUseTypeEnum {
@ -171,6 +175,10 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
return L10n.of(context).constructUseCorGCDesc;
case ConstructUseTypeEnum.incGC:
return L10n.of(context).constructUseIncGCDesc;
case ConstructUseTypeEnum.corGE:
return L10n.of(context).constructUseCorGEDesc;
case ConstructUseTypeEnum.incGE:
return L10n.of(context).constructUseIncGEDesc;
}
}
@ -213,6 +221,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.ignM:
case ConstructUseTypeEnum.corGC:
case ConstructUseTypeEnum.incGC:
case ConstructUseTypeEnum.corGE:
case ConstructUseTypeEnum.incGE:
return ActivityTypeEnum.morphId.icon;
case ConstructUseTypeEnum.em:
return ActivityTypeEnum.emoji.icon;
@ -246,6 +256,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.corLA:
case ConstructUseTypeEnum.corGC:
case ConstructUseTypeEnum.corGE:
return 5;
case ConstructUseTypeEnum.pvm:
@ -287,6 +298,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.incLA:
case ConstructUseTypeEnum.incGC:
case ConstructUseTypeEnum.incGE:
return -1;
case ConstructUseTypeEnum.incPA:
@ -340,6 +352,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.bonus:
case ConstructUseTypeEnum.corGC:
case ConstructUseTypeEnum.incGC:
case ConstructUseTypeEnum.corGE:
case ConstructUseTypeEnum.incGE:
return false;
}
}
@ -385,6 +399,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.corGC:
case ConstructUseTypeEnum.incGC:
case ConstructUseTypeEnum.corGE:
case ConstructUseTypeEnum.incGE:
return LearningSkillsEnum.reading;
case ConstructUseTypeEnum.pvm:
return LearningSkillsEnum.speaking;
@ -415,6 +431,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.corLA:
case ConstructUseTypeEnum.corGC:
case ConstructUseTypeEnum.corGE:
return SpaceAnalyticsSummaryEnum.numChoicesCorrect;
case ConstructUseTypeEnum.incIt:
@ -428,6 +445,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.incLA:
case ConstructUseTypeEnum.incGC:
case ConstructUseTypeEnum.incGE:
return SpaceAnalyticsSummaryEnum.numChoicesIncorrect;
case ConstructUseTypeEnum.ignIt:

View file

@ -26,18 +26,28 @@ import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_contr
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class PracticeChoice {
class VocabPracticeChoice {
final String choiceId;
final String choiceText;
final String? choiceEmoji;
const PracticeChoice({
const VocabPracticeChoice({
required this.choiceId,
required this.choiceText,
this.choiceEmoji,
});
}
class _PracticeQueueEntry {
final MessageActivityRequest request;
final Completer<MultipleChoicePracticeActivityModel> completer;
_PracticeQueueEntry({
required this.request,
required this.completer,
});
}
class SessionLoader extends AsyncLoader<AnalyticsPracticeSessionModel> {
final ConstructTypeEnum type;
SessionLoader({required this.type});
@ -67,12 +77,10 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
final ValueNotifier<AsyncState<MultipleChoicePracticeActivityModel>>
activityState = ValueNotifier(const AsyncState.idle());
final Queue<
MapEntry<PracticeTarget,
Completer<MultipleChoicePracticeActivityModel>>> _queue = Queue();
final Queue<_PracticeQueueEntry> _queue = Queue();
final ValueNotifier<PracticeTarget?> activityTarget =
ValueNotifier<PracticeTarget?>(null);
final ValueNotifier<MessageActivityRequest?> activityTarget =
ValueNotifier<MessageActivityRequest?>(null);
final ValueNotifier<double> progressNotifier = ValueNotifier<double>(0.0);
@ -116,13 +124,13 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
AnalyticsDataService get _analyticsService =>
Matrix.of(context).analyticsDataService;
List<PracticeChoice> filteredChoices(
List<VocabPracticeChoice> filteredChoices(
MultipleChoicePracticeActivityModel activity,
) {
final content = activity.multipleChoiceContent;
final choices = content.choices.toList();
final answer = content.answers.first;
final filtered = <PracticeChoice>[];
final filtered = <VocabPracticeChoice>[];
final seenTexts = <String>{};
for (final id in choices) {
@ -137,7 +145,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
(choice) => choice.choiceText == text,
);
if (index != -1) {
filtered[index] = PracticeChoice(
filtered[index] = VocabPracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: getChoiceEmoji(activity.storageKey, id),
@ -148,7 +156,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
seenTexts.add(text);
filtered.add(
PracticeChoice(
VocabPracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: getChoiceEmoji(activity.storageKey, id),
@ -202,7 +210,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
if (activityTarget.value == null) return;
if (widget.type != ConstructTypeEnum.vocab) return;
TtsController.tryToSpeak(
activityTarget.value!.tokens.first.vocabConstructID.lemma,
activityTarget.value!.target.tokens.first.vocabConstructID.lemma,
langCode: MatrixState.pangeaController.userController.userL2!.langCode,
);
}
@ -275,10 +283,10 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
activityState.value = const AsyncState.loading();
final nextActivityCompleter = _queue.removeFirst();
activityTarget.value = nextActivityCompleter.key;
activityTarget.value = nextActivityCompleter.request;
_playAudio();
final activity = await nextActivityCompleter.value.future;
final activity = await nextActivityCompleter.completer.future;
activityState.value = AsyncState.loaded(activity);
}
} catch (e) {
@ -298,7 +306,7 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
activityState.value = const AsyncState.loading();
final req = requests.first;
activityTarget.value = req.target;
activityTarget.value = req;
_playAudio();
final res = await _fetchActivity(req);
@ -314,10 +322,17 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
_fillActivityQueue(requests.skip(1).toList());
}
Future<void> _fillActivityQueue(List<MessageActivityRequest> requests) async {
Future<void> _fillActivityQueue(
List<MessageActivityRequest> requests,
) async {
for (final request in requests) {
final completer = Completer<MultipleChoicePracticeActivityModel>();
_queue.add(MapEntry(request.target, completer));
_queue.add(
_PracticeQueueEntry(
request: request,
completer: completer,
),
);
try {
final res = await _fetchActivity(request);

View file

@ -6,9 +6,32 @@ import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constant
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
class AnalyticsActivityTarget {
final PracticeTarget target;
final GrammarErrorRequestInfo? grammarErrorInfo;
AnalyticsActivityTarget({
required this.target,
this.grammarErrorInfo,
});
Map<String, dynamic> toJson() => {
'target': target.toJson(),
'grammarErrorInfo': grammarErrorInfo?.toJson(),
};
factory AnalyticsActivityTarget.fromJson(Map<String, dynamic> json) =>
AnalyticsActivityTarget(
target: PracticeTarget.fromJson(json['target']),
grammarErrorInfo: json['grammarErrorInfo'] != null
? GrammarErrorRequestInfo.fromJson(json['grammarErrorInfo'])
: null,
);
}
class AnalyticsPracticeSessionModel {
final DateTime startedAt;
final List<PracticeTarget> practiceTargets;
final List<AnalyticsActivityTarget> practiceTargets;
final String userL1;
final String userL2;
@ -38,7 +61,8 @@ class AnalyticsPracticeSessionModel {
userL1: userL1,
userL2: userL2,
activityQualityFeedback: null,
target: target,
target: target.target,
grammarErrorInfo: target.grammarErrorInfo,
);
}).toList();
}
@ -59,8 +83,8 @@ class AnalyticsPracticeSessionModel {
return AnalyticsPracticeSessionModel(
startedAt: DateTime.parse(json['startedAt'] as String),
practiceTargets: (json['practiceTargets'] as List<dynamic>)
.map((e) => PracticeTarget.fromJson(e))
.whereType<PracticeTarget>()
.map((e) => AnalyticsActivityTarget.fromJson(e))
.whereType<AnalyticsActivityTarget>()
.toList(),
userL1: json['userL1'] as String,
userL2: json['userL2'] as String,

View file

@ -1,14 +1,17 @@
import 'dart:math';
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_practice/analytics_practice_constants.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.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/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma.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/practice_target.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -24,28 +27,44 @@ class AnalyticsPracticeSessionRepo {
(_) => activityTypes[r.nextInt(activityTypes.length)],
);
final List<PracticeTarget> targets = [];
final List<AnalyticsActivityTarget> targets = [];
if (type == ConstructTypeEnum.vocab) {
final constructs = await _fetchVocab();
final targetCount = min(constructs.length, types.length);
targets.addAll([
for (var i = 0; i < targetCount; i++)
PracticeTarget(
tokens: [constructs[i].asToken],
activityType: types[i],
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: [constructs[i].asToken],
activityType: types[i],
),
),
]);
} else {
final morphs = await _fetchMorphs();
targets.addAll([
for (final entry in morphs.entries)
PracticeTarget(
tokens: [entry.key],
activityType: types[targets.length],
morphFeature: entry.value,
),
]);
final errorTargets = await _fetchErrors();
targets.addAll(errorTargets);
if (targets.length < AnalyticsPracticeConstants.practiceGroupSize) {
final morphs = await _fetchMorphs();
final remainingCount =
AnalyticsPracticeConstants.practiceGroupSize - targets.length;
final morphEntries = morphs.entries.take(remainingCount);
for (final entry in morphEntries) {
targets.add(
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: [entry.key],
activityType: types[targets.length],
morphFeature: entry.value,
),
),
);
}
targets.shuffle();
}
}
final session = AnalyticsPracticeSessionModel(
@ -144,4 +163,99 @@ class AnalyticsPracticeSessionRepo {
return targets;
}
static Future<List<AnalyticsActivityTarget>> _fetchErrors() async {
final uses = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getUses(count: 100, type: ConstructUseTypeEnum.ga);
final client = MatrixState.pangeaController.matrixState.client;
final Map<String, PangeaMessageEvent?> idsToEvents = {};
for (final use in uses) {
final eventID = use.metadata.eventId;
if (eventID == null || idsToEvents.containsKey(eventID)) continue;
final roomID = use.metadata.roomId;
if (roomID == null) {
idsToEvents[eventID] = null;
continue;
}
final room = client.getRoomById(roomID);
final event = await room?.getEventById(eventID);
if (event == null || event.redacted) {
idsToEvents[eventID] = null;
continue;
}
final timeline = await room!.getTimeline();
idsToEvents[eventID] = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: event.senderId == client.userID,
);
}
final l2Code =
MatrixState.pangeaController.userController.userL2!.langCodeShort;
final events = idsToEvents.values.whereType<PangeaMessageEvent>().toList();
final eventsWithContent = events.where((e) {
final originalSent = e.originalSent;
final choreo = originalSent?.choreo;
final tokens = originalSent?.tokens;
return originalSent?.langCode.split("-").first == l2Code &&
choreo != null &&
tokens != null &&
tokens.isNotEmpty &&
choreo.choreoSteps.any(
(step) =>
step.acceptedOrIgnoredMatch?.isGrammarMatch == true &&
step.acceptedOrIgnoredMatch?.match.bestChoice != null,
);
});
final targets = <AnalyticsActivityTarget>[];
for (final event in eventsWithContent) {
final originalSent = event.originalSent!;
final choreo = originalSent.choreo!;
final tokens = originalSent.tokens!;
for (int i = 0; i < choreo.choreoSteps.length; i++) {
final step = choreo.choreoSteps[i];
final igcMatch = step.acceptedOrIgnoredMatch;
if (igcMatch?.isGrammarMatch != true ||
igcMatch?.match.bestChoice == null) {
continue;
}
final choices = igcMatch!.match.choices!.map((c) => c.value).toList();
final choiceTokens = tokens.where(
(token) =>
token.lemma.saveVocab &&
choices.any(
(choice) => choice.contains(token.text.content),
),
);
targets.add(
AnalyticsActivityTarget(
target: PracticeTarget(
tokens: choiceTokens.toList(),
activityType: ActivityTypeEnum.grammarError,
morphFeature: null,
),
grammarErrorInfo: GrammarErrorRequestInfo(
choreo: choreo,
stepIndex: i,
eventID: event.eventId,
),
),
);
}
}
return targets;
}
}

View file

@ -143,8 +143,8 @@ class _AnalyticsActivityView extends StatelessWidget {
if (controller.widget.type ==
ConstructTypeEnum.vocab)
PhoneticTranscriptionWidget(
text:
target.tokens.first.vocabConstructID.lemma,
text: target
.target.tokens.first.vocabConstructID.lemma,
textLanguage: MatrixState
.pangeaController.userController.userL2!,
style: const TextStyle(fontSize: 14.0),
@ -157,13 +157,8 @@ class _AnalyticsActivityView extends StatelessWidget {
Expanded(
flex: 2,
child: Center(
child: ValueListenableBuilder(
valueListenable: controller.activityTarget,
builder: (context, target, __) => target != null
? _ExampleMessageWidget(
controller.getExampleMessage(target),
)
: const SizedBox(),
child: _AnalyticsPracticeCenterContent(
controller: controller,
),
),
),
@ -179,6 +174,36 @@ class _AnalyticsActivityView extends StatelessWidget {
}
}
class _AnalyticsPracticeCenterContent extends StatelessWidget {
final AnalyticsPracticeState controller;
const _AnalyticsPracticeCenterContent({
required this.controller,
});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: controller.activityTarget,
builder: (context, target, __) => switch (target?.target.activityType) {
null => const SizedBox(),
ActivityTypeEnum.grammarError => ValueListenableBuilder(
valueListenable: controller.activityState,
builder: (context, state, __) => switch (state) {
AsyncLoaded(value: final activity) => _ErrorBlankWidget(
activity: activity as GrammarErrorPracticeActivityModel,
),
_ => const SizedBox(),
},
),
_ => _ExampleMessageWidget(
controller.getExampleMessage(target!.target),
),
},
);
}
}
class _ExampleMessageWidget extends StatelessWidget {
final Future<List<InlineSpan>?> future;
@ -220,6 +245,62 @@ class _ExampleMessageWidget extends StatelessWidget {
}
}
class _ErrorBlankWidget extends StatelessWidget {
final GrammarErrorPracticeActivityModel activity;
const _ErrorBlankWidget({
required this.activity,
});
@override
Widget build(BuildContext context) {
final text = activity.text;
final errorOffset = activity.errorOffset;
final errorLength = activity.errorLength;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: Color.alphaBlend(
Colors.white.withAlpha(180),
ThemeData.dark().colorScheme.primary,
),
borderRadius: BorderRadius.circular(16),
),
child: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).colorScheme.onPrimaryFixed,
fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize,
),
children: [
if (errorOffset > 0)
TextSpan(text: text.characters.take(errorOffset).toString()),
WidgetSpan(
child: Container(
height: 4.0,
width: (errorLength * 8).toDouble(),
padding: const EdgeInsets.only(bottom: 2.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primary,
),
),
),
if (errorOffset + errorLength < text.length)
TextSpan(
text:
text.characters.skip(errorOffset + errorLength).toString(),
),
],
),
),
);
}
}
class _ActivityChoicesWidget extends StatelessWidget {
final AnalyticsPracticeState controller;
@ -366,6 +447,20 @@ class _ChoiceCard extends StatelessWidget {
isCorrect: isCorrect,
);
case ActivityTypeEnum.grammarError:
final activity = this.activity as GrammarErrorPracticeActivityModel;
return GameChoiceCard(
key: ValueKey(
'${activity.errorLength}_${activity.errorOffset}_${activity.eventID}_${activityType.name}_grammar_error_$choiceId',
),
shouldFlip: false,
targetId: targetId,
onPressed: onPressed,
isCorrect: isCorrect,
height: cardHeight,
child: Text(choiceText),
);
default:
return GameChoiceCard(
key: ValueKey(

View file

@ -0,0 +1,51 @@
import 'package:flutter/material.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';
class GrammarErrorPracticeGenerator {
static Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
assert(
req.grammarErrorInfo != null,
'Grammar error info must be provided for grammar error practice',
);
final choreo = req.grammarErrorInfo!.choreo;
final stepIndex = req.grammarErrorInfo!.stepIndex;
final eventID = req.grammarErrorInfo!.eventID;
final igcMatch =
choreo.choreoSteps[stepIndex].acceptedOrIgnoredMatch?.match;
assert(igcMatch?.choices != null, 'IGC match must have choices');
assert(igcMatch?.bestChoice != null, 'IGC match must have a best choice');
final correctChoice = igcMatch!.bestChoice!.value;
final choices = igcMatch.choices!.map((c) => c.value).toList();
final stepText = choreo.stepText(stepIndex: stepIndex - 1);
final errorSpan = stepText.characters
.skip(igcMatch.offset)
.take(igcMatch.length)
.toString();
choices.add(errorSpan);
choices.shuffle();
return MessageActivityResponse(
activity: GrammarErrorPracticeActivityModel(
tokens: req.target.tokens,
langCode: req.userL2,
multipleChoiceContent: MultipleChoiceActivity(
choices: choices.toSet(),
answers: {correctChoice},
),
text: stepText,
errorOffset: igcMatch.offset,
errorLength: igcMatch.length,
eventID: eventID,
),
);
}
}

View file

@ -132,6 +132,9 @@ class SpanData {
return choices![index];
}
String get errorSpan =>
fullText.characters.skip(offset).take(length).toString();
bool isNormalizationError() {
final correctChoice = choices
?.firstWhereOrNull(
@ -139,8 +142,6 @@ class SpanData {
)
?.value;
final errorSpan = fullText.characters.skip(offset).take(length).toString();
final l2Code =
MatrixState.pangeaController.userController.userL2?.langCodeShort;

View file

@ -15,7 +15,8 @@ enum ActivityTypeEnum {
messageMeaning,
lemmaMeaning,
lemmaAudio,
grammarCategory;
grammarCategory,
grammarError;
bool get includeTTSOnClick {
switch (this) {
@ -30,6 +31,7 @@ enum ActivityTypeEnum {
case ActivityTypeEnum.lemmaAudio:
case ActivityTypeEnum.lemmaMeaning:
case ActivityTypeEnum.grammarCategory:
case ActivityTypeEnum.grammarError:
return true;
}
}
@ -68,6 +70,9 @@ enum ActivityTypeEnum {
case 'grammar_category':
case 'grammarCategory':
return ActivityTypeEnum.grammarCategory;
case 'grammar_error':
case 'grammarError':
return ActivityTypeEnum.grammarError;
default:
throw Exception('Unknown activity type: $split');
}
@ -128,6 +133,11 @@ enum ActivityTypeEnum {
ConstructUseTypeEnum.corGC,
ConstructUseTypeEnum.incGC,
];
case ActivityTypeEnum.grammarError:
return [
ConstructUseTypeEnum.corGE,
ConstructUseTypeEnum.incGE,
];
}
}
@ -153,6 +163,8 @@ enum ActivityTypeEnum {
return ConstructUseTypeEnum.corLM;
case ActivityTypeEnum.grammarCategory:
return ConstructUseTypeEnum.corGC;
case ActivityTypeEnum.grammarError:
return ConstructUseTypeEnum.corGE;
}
}
@ -178,6 +190,8 @@ enum ActivityTypeEnum {
return ConstructUseTypeEnum.incLM;
case ActivityTypeEnum.grammarCategory:
return ConstructUseTypeEnum.incGC;
case ActivityTypeEnum.grammarError:
return ConstructUseTypeEnum.incGE;
}
}
@ -198,6 +212,7 @@ enum ActivityTypeEnum {
return Icons.format_shapes;
case ActivityTypeEnum.messageMeaning:
case ActivityTypeEnum.grammarCategory:
case ActivityTypeEnum.grammarError:
return Icons.star; // TODO: Add to L10n
}
}
@ -217,6 +232,7 @@ enum ActivityTypeEnum {
case ActivityTypeEnum.lemmaMeaning:
case ActivityTypeEnum.lemmaAudio:
case ActivityTypeEnum.grammarCategory:
case ActivityTypeEnum.grammarError:
return 1;
}
}
@ -235,6 +251,7 @@ enum ActivityTypeEnum {
static List<ActivityTypeEnum> get _grammarPracticeTypes => [
ActivityTypeEnum.grammarCategory,
ActivityTypeEnum.grammarError,
];
static List<ActivityTypeEnum> analyticsPracticeTypes(

View file

@ -1,5 +1,11 @@
import 'package:flutter/material.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/choreographer/choreo_record_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';
@ -35,23 +41,67 @@ class ActivityQualityFeedback {
}
}
class GrammarErrorRequestInfo {
final ChoreoRecordModel choreo;
final int stepIndex;
final String eventID;
const GrammarErrorRequestInfo({
required this.choreo,
required this.stepIndex,
required this.eventID,
});
Map<String, dynamic> toJson() {
return {
'choreo': choreo.toJson(),
'step_index': stepIndex,
'event_id': eventID,
};
}
factory GrammarErrorRequestInfo.fromJson(Map<String, dynamic> json) {
return GrammarErrorRequestInfo(
choreo: ChoreoRecordModel.fromJson(json['choreo']),
stepIndex: json['step_index'] as int,
eventID: json['event_id'] as String,
);
}
}
class MessageActivityRequest {
final String userL1;
final String userL2;
final PracticeTarget target;
final ActivityQualityFeedback? activityQualityFeedback;
final GrammarErrorRequestInfo? grammarErrorInfo;
MessageActivityRequest({
required this.userL1,
required this.userL2,
required this.activityQualityFeedback,
required this.target,
this.grammarErrorInfo,
}) {
if (target.tokens.isEmpty) {
throw Exception('Target tokens must not be empty');
}
}
String promptText(BuildContext context) {
switch (target.activityType) {
case ActivityTypeEnum.grammarCategory:
return L10n.of(context).whatIsTheMorphTag(
target.morphFeature!.getDisplayCopy(context),
target.tokens.first.text.content,
);
case ActivityTypeEnum.grammarError:
return L10n.of(context).fillInBlank;
default:
return target.tokens.first.vocabConstructID.lemma;
}
}
Map<String, dynamic> toJson() {
return {
'user_l1': userL1,
@ -60,6 +110,7 @@ class MessageActivityRequest {
'target_tokens': target.tokens.map((e) => e.toJson()).toList(),
'target_type': target.activityType.name,
'target_morph_feature': target.morphFeature,
'grammar_error_info': grammarErrorInfo?.toJson(),
};
}
@ -72,7 +123,8 @@ class MessageActivityRequest {
other.userL2 == userL2 &&
other.target == target &&
other.activityQualityFeedback?.feedbackText ==
activityQualityFeedback?.feedbackText;
activityQualityFeedback?.feedbackText &&
other.grammarErrorInfo == grammarErrorInfo;
}
@override
@ -80,7 +132,8 @@ class MessageActivityRequest {
return activityQualityFeedback.hashCode ^
target.hashCode ^
userL1.hashCode ^
userL2.hashCode;
userL2.hashCode ^
grammarErrorInfo.hashCode;
}
}

View file

@ -48,6 +48,8 @@ sealed class PracticeActivityModel {
return ActivityTypeEnum.morphId;
case WordListeningPracticeActivityModel():
return ActivityTypeEnum.wordFocusListening;
case GrammarErrorPracticeActivityModel():
return ActivityTypeEnum.grammarError;
}
}
@ -350,6 +352,24 @@ class LemmaPracticeActivityModel extends MultipleChoicePracticeActivityModel {
});
}
class GrammarErrorPracticeActivityModel
extends MultipleChoicePracticeActivityModel {
final String text;
final int errorOffset;
final int errorLength;
final String eventID;
GrammarErrorPracticeActivityModel({
required super.tokens,
required super.langCode,
required super.multipleChoiceContent,
required this.text,
required this.errorOffset,
required this.errorLength,
required this.eventID,
});
}
class EmojiPracticeActivityModel extends MatchPracticeActivityModel {
EmojiPracticeActivityModel({
required super.tokens,

View file

@ -9,6 +9,7 @@ import 'package:async/async.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/analytics_practice/grammar_error_practice_generator.dart';
import 'package:fluffychat/pangea/analytics_practice/morph_category_activity_generator.dart';
import 'package:fluffychat/pangea/analytics_practice/vocab_audio_activity_generator.dart';
import 'package:fluffychat/pangea/analytics_practice/vocab_meaning_activity_generator.dart';
@ -128,6 +129,12 @@ class PracticeRepo {
return VocabAudioActivityGenerator.get(req);
case ActivityTypeEnum.grammarCategory:
return MorphCategoryActivityGenerator.get(req);
case ActivityTypeEnum.grammarError:
assert(
req.grammarErrorInfo != null,
'Grammar error info must be provided for grammar error activities',
);
return GrammarErrorPracticeGenerator.get(req);
case ActivityTypeEnum.morphId:
return MorphActivityGenerator.get(req);
case ActivityTypeEnum.wordMeaning:

View file

@ -1,9 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/l10n/l10n.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';
@ -85,18 +83,6 @@ class PracticeTarget {
(morphFeature?.name ?? "");
}
String promptText(BuildContext context) {
switch (activityType) {
case ActivityTypeEnum.grammarCategory:
return L10n.of(context).whatIsTheMorphTag(
morphFeature!.getDisplayCopy(context),
tokens.first.text.content,
);
default:
return tokens.first.vocabConstructID.lemma;
}
}
ConstructIdentifier targetTokenConstructID(PangeaToken token) {
final defaultID = token.vocabConstructID;
final ConstructIdentifier? cId = morphFeature == null