skeleton of practice activities

This commit is contained in:
William Jordan-Cooley 2024-06-06 18:05:16 -04:00
parent c235842f35
commit 1dcd988be0
12 changed files with 615 additions and 49 deletions

View file

@ -3963,5 +3963,6 @@
"studentAnalyticsNotAvailable": "Student data not currently available",
"roomDataMissing": "Some data may be missing from rooms in which you are not a member.",
"updatePhoneOS": "You may need to update your device's OS version.",
"wordsPerMinute": "Words per minute"
"wordsPerMinute": "Words per minute",
"practice": "Practice"
}

View file

@ -23,4 +23,7 @@ class PangeaEventTypes {
static const String report = 'm.report';
static const textToSpeechRule = "p.rule.text_to_speech";
static const activityResponse = "pangea.activity_res";
static const acitivtyRequest = "pangea.activity_req";
}

View file

@ -3,7 +3,13 @@ import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:matrix/matrix.dart';
enum MessageMode { translation, definition, speechToText, textToSpeech }
enum MessageMode {
translation,
definition,
speechToText,
textToSpeech,
practiceActivity
}
extension MessageModeExtension on MessageMode {
IconData get icon {
@ -17,6 +23,8 @@ extension MessageModeExtension on MessageMode {
//TODO change icon for audio messages
case MessageMode.definition:
return Icons.book;
case MessageMode.practiceActivity:
return Symbols.fitness_center;
default:
return Icons.error; // Icon to indicate an error or unsupported mode
}
@ -32,6 +40,8 @@ extension MessageModeExtension on MessageMode {
return L10n.of(context)!.speechToTextTooltip;
case MessageMode.definition:
return L10n.of(context)!.definitions;
case MessageMode.practiceActivity:
return L10n.of(context)!.practice;
default:
return L10n.of(context)!
.oopsSomethingWentWrong; // Title to indicate an error or unsupported mode
@ -48,6 +58,8 @@ extension MessageModeExtension on MessageMode {
return L10n.of(context)!.speechToTextTooltip;
case MessageMode.definition:
return L10n.of(context)!.define;
case MessageMode.practiceActivity:
return L10n.of(context)!.practice;
default:
return L10n.of(context)!
.oopsSomethingWentWrong; // Title to indicate an error or unsupported mode
@ -58,6 +70,7 @@ extension MessageModeExtension on MessageMode {
switch (this) {
case MessageMode.translation:
case MessageMode.textToSpeech:
case MessageMode.practiceActivity:
case MessageMode.definition:
return event.messageType == MessageTypes.Text;
case MessageMode.speechToText:

View file

@ -26,6 +26,8 @@ extension PangeaEvent on Event {
return PangeaRepresentation.fromJson(json) as V;
case PangeaEventTypes.choreoRecord:
return ChoreoRecord.fromJson(json) as V;
case PangeaEventTypes.activityResponse:
return PangeaMessageTokens.fromJson(json) as V;
default:
throw Exception("$type events do not have pangea content");
}

View file

@ -4,11 +4,15 @@ import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/enum/audio_encoding_enum.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/class_model.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/models/speech_to_text_models.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
@ -601,6 +605,52 @@ class PangeaMessageEvent {
return steps;
}
List<PracticeActivityEvent> get _practiceActivityEvents => _latestEdit
.aggregatedEvents(
timeline,
PangeaEventTypes.activityResponse,
)
.map(
(e) => PracticeActivityEvent(
event: e,
),
)
.toList();
List<PracticeActivityModel> activities(String langCode) {
// final List<PracticeActivityEvent> practiceActivityEvents = _practiceActivityEvents;
// final List<PracticeActivityModel> activities = _practiceActivityEvents
// .map(
// (e) => PracticeActivityModel.fromJson(
// e.event.content,
// ),
// )
// .where(
// (element) => element.langCode == langCode,
// )
// .toList();
// return activities;
// for now, return a hard-coded activity
final PracticeActivityModel activityModel = PracticeActivityModel(
tgtConstructs: [
ConstructIdentifier(lemma: "be", type: ConstructType.vocab.string),
],
activityType: ActivityType.multipleChoice,
langCode: langCode,
msgId: _event.eventId,
multipleChoice: MultipleChoice(
question: "What is a synonym for 'happy'?",
choices: ["sad", "angry", "joyful", "tired"],
correctAnswer: "joyful",
),
);
return [activityModel];
}
// List<SpanData> get activities =>
//each match is turned into an activity that other students can access
//they're not told the answer but have to find it themselves

View file

@ -0,0 +1,29 @@
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class PracticeActivityEvent {
Event event;
PracticeActivityModel? _content;
PracticeActivityEvent({required this.event}) {
if (event.type != PangeaEventTypes.activityResponse) {
throw Exception(
"${event.type} should not be used to make a PracticeActivityEvent",
);
}
}
PracticeActivityModel? get practiceActivity {
try {
_content ??= event.getPangeaContent<PracticeActivityModel>();
return _content!;
} catch (err, s) {
ErrorHandler.logError(e: err, s: s);
return null;
}
}
}

View file

@ -0,0 +1,62 @@
class MultipleChoice {
final String question;
final List<String> choices;
final String correctAnswer;
MultipleChoice({
required this.question,
required this.choices,
required this.correctAnswer,
});
bool get isValidQuestion => choices.contains(correctAnswer);
int get correctAnswerIndex => choices.indexOf(correctAnswer);
factory MultipleChoice.fromJson(Map<String, dynamic> json) {
return MultipleChoice(
question: json['question'] as String,
choices: (json['choices'] as List).map((e) => e as String).toList(),
correctAnswer: json['correct_answer'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'question': question,
'choices': choices,
'correct_answer': correctAnswer,
};
}
}
// record the options that the user selected
// note that this is not the same as the correct answer
// the user might have selected multiple options before
// finding the answer
class MultipleChoiceActivityCompletionRecord {
final String question;
List<String> selectedOptions;
MultipleChoiceActivityCompletionRecord({
required this.question,
this.selectedOptions = const [],
});
factory MultipleChoiceActivityCompletionRecord.fromJson(
Map<String, dynamic> json,
) {
return MultipleChoiceActivityCompletionRecord(
question: json['question'] as String,
selectedOptions:
(json['selected_options'] as List).map((e) => e as String).toList(),
);
}
Map<String, dynamic> toJson() {
return {
'question': question,
'selected_options': selectedOptions,
};
}
}

View file

@ -0,0 +1,223 @@
import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart';
class ConstructIdentifier {
final String lemma;
final String type;
ConstructIdentifier({required this.lemma, required this.type});
factory ConstructIdentifier.fromJson(Map<String, dynamic> json) {
return ConstructIdentifier(
lemma: json['lemma'] as String,
type: json['type'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'lemma': lemma,
'type': type,
};
}
}
enum ActivityType { multipleChoice, freeResponse, listening, speaking }
class MessageInfo {
final String msgId;
final String roomId;
final String text;
MessageInfo({required this.msgId, required this.roomId, required this.text});
factory MessageInfo.fromJson(Map<String, dynamic> json) {
return MessageInfo(
msgId: json['msg_id'] as String,
roomId: json['room_id'] as String,
text: json['text'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'msg_id': msgId,
'room_id': roomId,
'text': text,
};
}
}
class ActivityRequest {
final String mode;
final List<ConstructIdentifier>? targetConstructs;
final List<MessageInfo>? candidateMessages;
final List<String>? userIds;
final ActivityType? activityType;
final int numActivities;
ActivityRequest({
required this.mode,
this.targetConstructs,
this.candidateMessages,
this.userIds,
this.activityType,
this.numActivities = 10,
});
factory ActivityRequest.fromJson(Map<String, dynamic> json) {
return ActivityRequest(
mode: json['mode'] as String,
targetConstructs: (json['target_constructs'] as List?)
?.map((e) => ConstructIdentifier.fromJson(e as Map<String, dynamic>))
.toList(),
candidateMessages: (json['candidate_msgs'] as List)
.map((e) => MessageInfo.fromJson(e as Map<String, dynamic>))
.toList(),
userIds: (json['user_ids'] as List?)?.map((e) => e as String).toList(),
activityType: ActivityType.values.firstWhere(
(e) => e.toString().split('.').last == json['activity_type'],
),
numActivities: json['num_activities'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'mode': mode,
'target_constructs': targetConstructs?.map((e) => e.toJson()).toList(),
'candidate_msgs': candidateMessages?.map((e) => e.toJson()).toList(),
'user_ids': userIds,
'activity_type': activityType?.toString().split('.').last,
'num_activities': numActivities,
};
}
}
class FreeResponse {
final String question;
final String correctAnswer;
final String gradingGuide;
FreeResponse({
required this.question,
required this.correctAnswer,
required this.gradingGuide,
});
factory FreeResponse.fromJson(Map<String, dynamic> json) {
return FreeResponse(
question: json['question'] as String,
correctAnswer: json['correct_answer'] as String,
gradingGuide: json['grading_guide'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'question': question,
'correct_answer': correctAnswer,
'grading_guide': gradingGuide,
};
}
}
class Listening {
final String audioUrl;
final String text;
Listening({required this.audioUrl, required this.text});
factory Listening.fromJson(Map<String, dynamic> json) {
return Listening(
audioUrl: json['audio_url'] as String,
text: json['text'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'audio_url': audioUrl,
'text': text,
};
}
}
class Speaking {
final String text;
Speaking({required this.text});
factory Speaking.fromJson(Map<String, dynamic> json) {
return Speaking(
text: json['text'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'text': text,
};
}
}
class PracticeActivityModel {
final List<ConstructIdentifier> tgtConstructs;
final String langCode;
final String msgId;
final ActivityType activityType;
final MultipleChoice? multipleChoice;
final Listening? listening;
final Speaking? speaking;
final FreeResponse? freeResponse;
PracticeActivityModel({
required this.tgtConstructs,
required this.langCode,
required this.msgId,
required this.activityType,
this.multipleChoice,
this.listening,
this.speaking,
this.freeResponse,
});
factory PracticeActivityModel.fromJson(Map<String, dynamic> json) {
return PracticeActivityModel(
tgtConstructs: (json['tgt_constructs'] as List)
.map((e) => ConstructIdentifier.fromJson(e as Map<String, dynamic>))
.toList(),
langCode: json['lang_code'] as String,
msgId: json['msg_id'] as String,
activityType: ActivityType.values.firstWhere(
(e) => e.toString().split('.').last == json['activity_type'],
),
multipleChoice: json['multiple_choice'] != null
? MultipleChoice.fromJson(
json['multiple_choice'] as Map<String, dynamic>,
)
: null,
listening: json['listening'] != null
? Listening.fromJson(json['listening'] as Map<String, dynamic>)
: null,
speaking: json['speaking'] != null
? Speaking.fromJson(json['speaking'] as Map<String, dynamic>)
: null,
freeResponse: json['free_response'] != null
? FreeResponse.fromJson(json['free_response'] as Map<String, dynamic>)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(),
'lang_code': langCode,
'msg_id': msgId,
'activity_type': activityType.toString().split('.').last,
'multiple_choice': multipleChoice?.toJson(),
'listening': listening?.toJson(),
'speaking': speaking?.toJson(),
'free_response': freeResponse?.toJson(),
};
}
}

View file

@ -16,6 +16,7 @@ import 'package:fluffychat/pangea/widgets/chat/message_translation_card.dart';
import 'package:fluffychat/pangea/widgets/chat/message_unsubscribed_card.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_message.dart';
import 'package:fluffychat/pangea/widgets/igc/word_data_card.dart';
import 'package:fluffychat/pangea/widgets/practice_activity_card/message_practice_activity_card.dart';
import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
@ -215,6 +216,9 @@ class MessageToolbarState extends State<MessageToolbar> {
case MessageMode.definition:
showDefinition();
break;
case MessageMode.practiceActivity:
showPracticeActivity();
break;
default:
ErrorHandler.logError(
e: "Invalid toolbar mode",
@ -272,6 +276,15 @@ class MessageToolbarState extends State<MessageToolbar> {
);
}
void showPracticeActivity() {
toolbarContent = PracticeActivityCard(
practiceActivity: widget.pangeaMessageEvent
// @ggurdin - is this the best way to get the l2 language here?
.activities(widget.pangeaMessageEvent.messageDisplayLangCode)
.first,
);
}
void showImage() {}
void spellCheck() {}

View file

@ -0,0 +1,38 @@
//stateful widget that displays a card with a practice activity
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/widgets/practice_activity_card/multiple_choice_activity.dart';
import 'package:flutter/material.dart';
class PracticeActivityCard extends StatefulWidget {
final PracticeActivityModel practiceActivity;
const PracticeActivityCard({
super.key,
required this.practiceActivity,
});
@override
MessagePracticeActivityCardState createState() =>
MessagePracticeActivityCardState();
}
//parameters for the stateful widget
// practiceActivity: the practice activity to display
// use a switch statement based on the type of the practice activity to display the appropriate content
// just use different widgets for the different types, don't define in this file
// for multiple choice, use the MultipleChoiceActivity widget
// for the rest, just return SizedBox.shrink() for now
class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
@override
Widget build(BuildContext context) {
switch (widget.practiceActivity.activityType) {
case ActivityType.multipleChoice:
return MultipleChoiceActivity(
practiceActivity: widget.practiceActivity,
);
default:
return const SizedBox.shrink();
}
}
}

View file

@ -0,0 +1,81 @@
// stateful widget that displays a card with a practice activity of type multiple choice
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/material.dart';
class MultipleChoiceActivity extends StatefulWidget {
final PracticeActivityModel practiceActivity;
const MultipleChoiceActivity({
super.key,
required this.practiceActivity,
});
@override
MultipleChoiceActivityState createState() => MultipleChoiceActivityState();
}
//parameters for the stateful widget
// practiceActivity: the practice activity to display
// show the question text and choices
// use the ChoiceArray widget to display the choices
class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
int? selectedChoiceIndex;
late MultipleChoiceActivityCompletionRecord? completionRecord;
@override
initState() {
super.initState();
selectedChoiceIndex = null;
completionRecord = MultipleChoiceActivityCompletionRecord(
question: widget.practiceActivity.multipleChoice!.question,
);
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(8),
child: Column(
children: [
Text(
widget.practiceActivity.multipleChoice!.question,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ChoicesArray(
isLoading: false,
uniqueKeyForLayerLink: (index) => "multiple_choice_$index",
onLongPress: null,
onPressed: (index) {
selectedChoiceIndex = index;
completionRecord!.selectedOptions
.add(widget.practiceActivity.multipleChoice!.choices[index]);
setState(() {});
},
originalSpan: "placeholder",
selectedChoiceIndex: selectedChoiceIndex,
choices: widget.practiceActivity.multipleChoice!.choices
.mapIndexed(
(int index, String value) => Choice(
text: value,
color: null,
isGold:
widget.practiceActivity.multipleChoice!.correctAnswer ==
value,
),
)
.toList(),
),
],
),
);
}
}

View file

@ -839,7 +839,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"be": [
@ -2277,7 +2278,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"bn": [
@ -3177,7 +3179,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"bo": [
@ -4077,7 +4080,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ca": [
@ -4977,7 +4981,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"cs": [
@ -5877,7 +5882,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"de": [
@ -6724,7 +6730,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"el": [
@ -7624,7 +7631,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"eo": [
@ -8524,7 +8532,12 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"es": [
"practice"
],
"et": [
@ -9367,7 +9380,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"eu": [
@ -10210,7 +10224,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"fa": [
@ -11110,7 +11125,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"fi": [
@ -12010,7 +12026,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"fr": [
@ -12910,7 +12927,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ga": [
@ -13810,7 +13828,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"gl": [
@ -14653,7 +14672,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"he": [
@ -15553,7 +15573,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"hi": [
@ -16453,7 +16474,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"hr": [
@ -17340,7 +17362,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"hu": [
@ -18240,7 +18263,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ia": [
@ -19664,7 +19688,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"id": [
@ -20564,7 +20589,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ie": [
@ -21464,7 +21490,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"it": [
@ -22349,7 +22376,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ja": [
@ -23249,7 +23277,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ko": [
@ -24149,7 +24178,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"lt": [
@ -25049,7 +25079,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"lv": [
@ -25949,7 +25980,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"nb": [
@ -26849,7 +26881,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"nl": [
@ -27749,7 +27782,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"pl": [
@ -28649,7 +28683,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"pt": [
@ -29549,7 +29584,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"pt_BR": [
@ -30418,7 +30454,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"pt_PT": [
@ -31318,7 +31355,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ro": [
@ -32218,7 +32256,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ru": [
@ -33061,7 +33100,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"sk": [
@ -33961,7 +34001,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"sl": [
@ -34861,7 +34902,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"sr": [
@ -35761,7 +35803,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"sv": [
@ -36626,7 +36669,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"ta": [
@ -37526,7 +37570,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"th": [
@ -38426,7 +38471,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"tr": [
@ -39311,7 +39357,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"uk": [
@ -40154,7 +40201,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"vi": [
@ -41054,7 +41102,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"zh": [
@ -41897,7 +41946,8 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
],
"zh_Hant": [
@ -42797,6 +42847,7 @@
"studentAnalyticsNotAvailable",
"roomDataMissing",
"updatePhoneOS",
"wordsPerMinute"
"wordsPerMinute",
"practice"
]
}