diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb index 1d5ff4218..0bb035da0 100644 --- a/assets/l10n/intl_en.arb +++ b/assets/l10n/intl_en.arb @@ -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" } \ No newline at end of file diff --git a/lib/pangea/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart index cfdb7f0d7..df37724b3 100644 --- a/lib/pangea/constants/pangea_event_types.dart +++ b/lib/pangea/constants/pangea_event_types.dart @@ -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"; } diff --git a/lib/pangea/enum/message_mode_enum.dart b/lib/pangea/enum/message_mode_enum.dart index 25948d23b..f25140a9c 100644 --- a/lib/pangea/enum/message_mode_enum.dart +++ b/lib/pangea/enum/message_mode_enum.dart @@ -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: diff --git a/lib/pangea/extensions/pangea_event_extension.dart b/lib/pangea/extensions/pangea_event_extension.dart index f62f27925..dcc3a8bec 100644 --- a/lib/pangea/extensions/pangea_event_extension.dart +++ b/lib/pangea/extensions/pangea_event_extension.dart @@ -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"); } diff --git a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart index 5fa2e2659..c874dc93c 100644 --- a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart +++ b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart @@ -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 get _practiceActivityEvents => _latestEdit + .aggregatedEvents( + timeline, + PangeaEventTypes.activityResponse, + ) + .map( + (e) => PracticeActivityEvent( + event: e, + ), + ) + .toList(); + + List activities(String langCode) { + // final List practiceActivityEvents = _practiceActivityEvents; + + // final List 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 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 diff --git a/lib/pangea/matrix_event_wrappers/practice_activity_event.dart b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart new file mode 100644 index 000000000..3e5fa5f16 --- /dev/null +++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart @@ -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(); + return _content!; + } catch (err, s) { + ErrorHandler.logError(e: err, s: s); + return null; + } + } +} diff --git a/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart new file mode 100644 index 000000000..a0007e754 --- /dev/null +++ b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart @@ -0,0 +1,62 @@ +class MultipleChoice { + final String question; + final List 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 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 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 selectedOptions; + + MultipleChoiceActivityCompletionRecord({ + required this.question, + this.selectedOptions = const [], + }); + + factory MultipleChoiceActivityCompletionRecord.fromJson( + Map json, + ) { + return MultipleChoiceActivityCompletionRecord( + question: json['question'] as String, + selectedOptions: + (json['selected_options'] as List).map((e) => e as String).toList(), + ); + } + + Map toJson() { + return { + 'question': question, + 'selected_options': selectedOptions, + }; + } +} diff --git a/lib/pangea/models/practice_activities.dart/practice_activity_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart new file mode 100644 index 000000000..10aaefa87 --- /dev/null +++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart @@ -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 json) { + return ConstructIdentifier( + lemma: json['lemma'] as String, + type: json['type'] as String, + ); + } + + Map 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 json) { + return MessageInfo( + msgId: json['msg_id'] as String, + roomId: json['room_id'] as String, + text: json['text'] as String, + ); + } + + Map toJson() { + return { + 'msg_id': msgId, + 'room_id': roomId, + 'text': text, + }; + } +} + +class ActivityRequest { + final String mode; + final List? targetConstructs; + final List? candidateMessages; + final List? 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 json) { + return ActivityRequest( + mode: json['mode'] as String, + targetConstructs: (json['target_constructs'] as List?) + ?.map((e) => ConstructIdentifier.fromJson(e as Map)) + .toList(), + candidateMessages: (json['candidate_msgs'] as List) + .map((e) => MessageInfo.fromJson(e as Map)) + .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 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 json) { + return FreeResponse( + question: json['question'] as String, + correctAnswer: json['correct_answer'] as String, + gradingGuide: json['grading_guide'] as String, + ); + } + + Map 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 json) { + return Listening( + audioUrl: json['audio_url'] as String, + text: json['text'] as String, + ); + } + + Map toJson() { + return { + 'audio_url': audioUrl, + 'text': text, + }; + } +} + +class Speaking { + final String text; + + Speaking({required this.text}); + + factory Speaking.fromJson(Map json) { + return Speaking( + text: json['text'] as String, + ); + } + + Map toJson() { + return { + 'text': text, + }; + } +} + +class PracticeActivityModel { + final List 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 json) { + return PracticeActivityModel( + tgtConstructs: (json['tgt_constructs'] as List) + .map((e) => ConstructIdentifier.fromJson(e as Map)) + .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, + ) + : null, + listening: json['listening'] != null + ? Listening.fromJson(json['listening'] as Map) + : null, + speaking: json['speaking'] != null + ? Speaking.fromJson(json['speaking'] as Map) + : null, + freeResponse: json['free_response'] != null + ? FreeResponse.fromJson(json['free_response'] as Map) + : null, + ); + } + + Map 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(), + }; + } +} diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart index 142a27227..523637b37 100644 --- a/lib/pangea/widgets/chat/message_toolbar.dart +++ b/lib/pangea/widgets/chat/message_toolbar.dart @@ -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 { 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 { ); } + 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() {} diff --git a/lib/pangea/widgets/practice_activity_card/message_practice_activity_card.dart b/lib/pangea/widgets/practice_activity_card/message_practice_activity_card.dart new file mode 100644 index 000000000..c5bb5dbfa --- /dev/null +++ b/lib/pangea/widgets/practice_activity_card/message_practice_activity_card.dart @@ -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 { + @override + Widget build(BuildContext context) { + switch (widget.practiceActivity.activityType) { + case ActivityType.multipleChoice: + return MultipleChoiceActivity( + practiceActivity: widget.practiceActivity, + ); + default: + return const SizedBox.shrink(); + } + } +} diff --git a/lib/pangea/widgets/practice_activity_card/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity_card/multiple_choice_activity.dart new file mode 100644 index 000000000..f8bec5436 --- /dev/null +++ b/lib/pangea/widgets/practice_activity_card/multiple_choice_activity.dart @@ -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 { + 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(), + ), + ], + ), + ); + } +} diff --git a/needed-translations.txt b/needed-translations.txt index bb967d011..6a6224756 100644 --- a/needed-translations.txt +++ b/needed-translations.txt @@ -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" ] }