Merge branch 'main' into analytics-target-languages

pull/1384/head
ggurdin 1 year ago committed by GitHub
commit 51d684ac81
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -31,7 +31,7 @@
# Special thanks
* Pangea Chat is a fork of [FluffyChat](https://fluffychat.im), is an open source, nonprofit and cute [[matrix](https://matrix.org)] client written in [Flutter](https://flutter.dev). The goal of FluffyChat is to create an easy to use instant messenger which is open source and accessible for everyone. You can [support the primary maker of FluffyChat directly here.](https://ko-fi.com/C1C86VN53)
* Pangea Chat is a fork of [FluffyChat](https://fluffychat.im) which is a [[matrix](https://matrix.org)] client written in [Flutter](https://flutter.dev). You can [support the primary maker of FluffyChat directly here.](https://ko-fi.com/C1C86VN53)
* <a href="https://github.com/fabiyamada">Fabiyamada</a> is a graphics designer and has made the fluffychat logo and the banner. Big thanks for her great designs.

@ -4010,9 +4010,9 @@
"wordsPerMinute": "Words per minute",
"autoIGCToolName": "Run Language Assistance Automatically",
"autoIGCToolDescription": "Automatically run language assistance after typing messages",
"runGrammarCorrection": "Run grammar correction",
"runGrammarCorrection": "Check message",
"grammarCorrectionFailed": "Issues to address",
"grammarCorrectionComplete": "Grammar correction complete",
"grammarCorrectionComplete": "Looks good!",
"leaveRoomDescription": "The chat will be moved to the archive. Other users will be able to see that you have left the chat.",
"archiveSpaceDescription": "All chats within this space will be moved to the archive for yourself and other non-admin users.",
"leaveSpaceDescription": "All chats within this space will be moved to the archive. Other users will be able to see that you have left the space.",
@ -4055,5 +4055,8 @@
"spaceAnalytics": "Space Analytics",
"changeAnalyticsLanguage": "Change Analytics Language",
"suggestToSpace": "Suggest this space",
"suggestToSpaceDesc": "Suggested spaces will appear in the chat lists for their parent spaces"
"suggestToSpaceDesc": "Suggested spaces will appear in the chat lists for their parent spaces",
"practice": "Practice",
"noLanguagesSet": "No languages set",
"noActivitiesFound": "No practice activities found for this message"
}

@ -4609,9 +4609,9 @@
"enterNumber": "Introduzca un valor numérico entero.",
"autoIGCToolName": "Ejecutar automáticamente la asistencia lingüística",
"autoIGCToolDescription": "Ejecutar automáticamente la asistencia lingüística después de escribir mensajes",
"runGrammarCorrection": "Corregir la gramática",
"runGrammarCorrection": "Comprobar mensaje",
"grammarCorrectionFailed": "Cuestiones a tratar",
"grammarCorrectionComplete": "Corrección gramatical completa",
"grammarCorrectionComplete": "¡Se ve bien!",
"leaveRoomDescription": "El chat se moverá al archivo. Los demás usuarios podrán ver que has abandonado el chat.",
"archiveSpaceDescription": "Todos los chats de este espacio se moverán al archivo para ti y otros usuarios que no sean administradores.",
"leaveSpaceDescription": "Todos los chats dentro de este espacio se moverán al archivo. Los demás usuarios podrán ver que has abandonado el espacio.",

@ -3,6 +3,7 @@ import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/enum/use_type.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/language_model.dart';
import 'package:fluffychat/pangea/widgets/chat/message_buttons.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/string_color.dart';
@ -524,7 +525,14 @@ class Message extends StatelessWidget {
Widget container;
final showReceiptsRow =
event.hasAggregatedEvents(timeline, RelationshipTypes.reaction);
if (showReceiptsRow || displayTime || selected || displayReadMarker) {
// #Pangea
// if (showReceiptsRow || displayTime || selected || displayReadMarker) {
if (showReceiptsRow ||
displayTime ||
selected ||
displayReadMarker ||
(pangeaMessageEvent?.showMessageButtons ?? false)) {
// Pangea#
container = Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
@ -561,7 +569,11 @@ class Message extends StatelessWidget {
AnimatedSize(
duration: FluffyThemes.animationDuration,
curve: FluffyThemes.animationCurve,
child: !showReceiptsRow
// #Pangea
child: !showReceiptsRow &&
!(pangeaMessageEvent?.showMessageButtons ?? false)
// child: !showReceiptsRow
// Pangea#
? const SizedBox.shrink()
: Padding(
padding: EdgeInsets.only(
@ -569,7 +581,19 @@ class Message extends StatelessWidget {
left: (ownMessage ? 0 : Avatar.defaultSize) + 12.0,
right: ownMessage ? 0 : 12.0,
),
child: MessageReactions(event, timeline),
// #Pangea
child: Row(
mainAxisAlignment: ownMessage
? MainAxisAlignment.end
: MainAxisAlignment.start,
children: [
if (pangeaMessageEvent?.showMessageButtons ?? false)
MessageButtons(toolbarController: toolbarController),
MessageReactions(event, timeline),
],
),
// child: MessageReactions(event, timeline),
// Pangea#
),
),
if (displayReadMarker)

@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:io';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
@ -213,32 +212,12 @@ class ChatListController extends State<ChatList>
}
List<Room> get filteredRooms => Matrix.of(context)
.client
.rooms
.where(
getRoomFilterByActiveFilter(activeFilter),
)
// #Pangea
.sorted((roomA, roomB) {
// put rooms with unread messages at the top of the list
if (roomA.membership == Membership.invite &&
roomB.membership != Membership.invite) {
return -1;
}
if (roomA.membership != Membership.invite &&
roomB.membership == Membership.invite) {
return 1;
}
final bool aUnread = roomA.notificationCount > 0 || roomA.markedUnread;
final bool bUnread = roomB.notificationCount > 0 || roomB.markedUnread;
if (aUnread && !bUnread) return -1;
if (!aUnread && bUnread) return 1;
return 0;
})
// Pangea#
.toList();
.client
.rooms
.where(
getRoomFilterByActiveFilter(activeFilter),
)
.toList();
bool isSearchMode = false;
Future<QueryPublicRoomsResponse>? publicRoomsResponse;

@ -1,5 +1,6 @@
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/utils/find_conversation_partner_dialog.dart';
import 'package:fluffychat/pangea/utils/logout.dart';
import 'package:fluffychat/pangea/utils/space_code.dart';
@ -68,7 +69,9 @@ class ClientChooserButton extends StatelessWidget {
),
),
PopupMenuItem(
enabled: matrix.client.rooms.isNotEmpty,
enabled: matrix.client.rooms.any(
(room) => !room.isSpace && !room.isArchived && !room.isAnalyticsRoom,
),
value: SettingsAction.myAnalytics,
child: Row(
children: [

@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/choreographer/controllers/message_options.dart
import 'package:fluffychat/pangea/constants/language_keys.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/enum/assistance_state_enum.dart';
import 'package:fluffychat/pangea/enum/edit_type.dart';
import 'package:fluffychat/pangea/models/it_step.dart';
import 'package:fluffychat/pangea/models/language_detection_model.dart';
@ -570,13 +571,3 @@ class Choreographer {
return AssistanceState.complete;
}
}
// assistance state is, user has not typed a message, user has typed a message and IGC has not run,
// IGC is running, IGC has run and there are remaining steps (either IT or IGC), or all steps are done
enum AssistanceState {
noMessage,
notFetched,
fetching,
fetched,
complete,
}

@ -3,9 +3,7 @@ import 'dart:math';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import '../../utils/bot_style.dart';
import 'it_shimmer.dart';
@ -18,6 +16,10 @@ class ChoicesArray extends StatelessWidget {
final int? selectedChoiceIndex;
final String originalSpan;
final String Function(int) uniqueKeyForLayerLink;
/// some uses of this widget want to disable the choices
final bool isActive;
const ChoicesArray({
super.key,
required this.isLoading,
@ -26,6 +28,7 @@ class ChoicesArray extends StatelessWidget {
required this.originalSpan,
required this.uniqueKeyForLayerLink,
required this.selectedChoiceIndex,
this.isActive = true,
this.onLongPress,
});
@ -42,8 +45,8 @@ class ChoicesArray extends StatelessWidget {
.map(
(entry) => ChoiceItem(
theme: theme,
onLongPress: onLongPress,
onPressed: onPressed,
onLongPress: isActive ? onLongPress : null,
onPressed: isActive ? onPressed : (_) {},
entry: entry,
isSelected: selectedChoiceIndex == entry.key,
),
@ -109,19 +112,19 @@ class ChoiceItem extends StatelessWidget {
: null,
child: TextButton(
style: ButtonStyle(
padding: MaterialStateProperty.all(
padding: WidgetStateProperty.all(
const EdgeInsets.symmetric(horizontal: 7),
),
//if index is selected, then give the background a slight primary color
backgroundColor: MaterialStateProperty.all<Color>(
backgroundColor: WidgetStateProperty.all<Color>(
entry.value.color != null
? entry.value.color!.withOpacity(0.2)
: theme.colorScheme.primary.withOpacity(0.1),
),
textStyle: MaterialStateProperty.all(
textStyle: WidgetStateProperty.all(
BotStyle.text(context),
),
shape: MaterialStateProperty.all(
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
@ -177,21 +180,21 @@ class ChoiceAnimationWidgetState extends State<ChoiceAnimationWidget>
);
_animation = widget.isGold
? Tween<double>(begin: 1.0, end: 1.2).animate(_controller)
: TweenSequence<double>([
TweenSequenceItem<double>(
tween: Tween<double>(begin: 0, end: -8 * pi / 180),
weight: 1.0,
),
TweenSequenceItem<double>(
tween: Tween<double>(begin: -8 * pi / 180, end: 16 * pi / 180),
weight: 2.0,
),
TweenSequenceItem<double>(
tween: Tween<double>(begin: 16 * pi / 180, end: 0),
weight: 1.0,
),
]).animate(_controller);
? Tween<double>(begin: 1.0, end: 1.2).animate(_controller)
: TweenSequence<double>([
TweenSequenceItem<double>(
tween: Tween<double>(begin: 0, end: -8 * pi / 180),
weight: 1.0,
),
TweenSequenceItem<double>(
tween: Tween<double>(begin: -8 * pi / 180, end: 16 * pi / 180),
weight: 2.0,
),
TweenSequenceItem<double>(
tween: Tween<double>(begin: 16 * pi / 180, end: 0),
weight: 1.0,
),
]).animate(_controller);
if (widget.selected && !animationPlayed) {
_controller.forward();
@ -221,28 +224,28 @@ class ChoiceAnimationWidgetState extends State<ChoiceAnimationWidget>
@override
Widget build(BuildContext context) {
return widget.isGold
? AnimatedBuilder(
key: UniqueKey(),
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: child,
);
},
child: widget.child,
)
: AnimatedBuilder(
key: UniqueKey(),
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: _animation.value,
child: child,
);
},
child: widget.child,
);
? AnimatedBuilder(
key: UniqueKey(),
animation: _animation,
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: child,
);
},
child: widget.child,
)
: AnimatedBuilder(
key: UniqueKey(),
animation: _animation,
builder: (context, child) {
return Transform.rotate(
angle: _animation.value,
child: child,
);
},
child: widget.child,
);
}
@override

@ -1,10 +1,9 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/choreographer/controllers/choreographer.dart';
import 'package:fluffychat/pangea/constants/colors.dart';
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/enum/assistance_state_enum.dart';
import 'package:fluffychat/pangea/widgets/user_settings/p_language_dialog.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -54,15 +53,15 @@ class StartIGCButtonState extends State<StartIGCButton>
setState(() => prevState = assistanceState);
}
bool get itEnabled => widget.controller.choreographer.itEnabled;
bool get igcEnabled => widget.controller.choreographer.igcEnabled;
CanSendStatus get canSendStatus =>
widget.controller.pangeaController.subscriptionController.canSendStatus;
bool get grammarCorrectionEnabled =>
(itEnabled || igcEnabled) && canSendStatus == CanSendStatus.subscribed;
@override
Widget build(BuildContext context) {
final bool itEnabled = widget.controller.choreographer.itEnabled;
final bool igcEnabled = widget.controller.choreographer.igcEnabled;
final CanSendStatus canSendStatus =
widget.controller.pangeaController.subscriptionController.canSendStatus;
final bool grammarCorrectionEnabled =
(itEnabled || igcEnabled) && canSendStatus == CanSendStatus.subscribed;
if (!grammarCorrectionEnabled ||
widget.controller.choreographer.isAutoIGCEnabled ||
widget.controller.choreographer.choreoMode == ChoreoMode.it) {
@ -89,7 +88,7 @@ class StartIGCButtonState extends State<StartIGCButton>
disabledElevation: 0,
shape: const CircleBorder(),
onPressed: () {
if (assistanceState != AssistanceState.complete) {
if (assistanceState != AssistanceState.fetching) {
widget.controller.choreographer
.getLanguageHelp(
false,
@ -142,32 +141,3 @@ class StartIGCButtonState extends State<StartIGCButton>
);
}
}
extension AssistanceStateExtension on AssistanceState {
Color stateColor(context) {
switch (this) {
case AssistanceState.noMessage:
case AssistanceState.notFetched:
case AssistanceState.fetching:
return Theme.of(context).colorScheme.primary;
case AssistanceState.fetched:
return PangeaColors.igcError;
case AssistanceState.complete:
return AppConfig.success;
}
}
String tooltip(L10n l10n) {
switch (this) {
case AssistanceState.noMessage:
case AssistanceState.notFetched:
return l10n.runGrammarCorrection;
case AssistanceState.fetching:
return "";
case AssistanceState.fetched:
return l10n.grammarCorrectionFailed;
case AssistanceState.complete:
return l10n.grammarCorrectionComplete;
}
}
}

@ -25,4 +25,8 @@ class PangeaEventTypes {
static const String report = 'm.report';
static const textToSpeechRule = "p.rule.text_to_speech";
static const pangeaActivityRes = "pangea.activity_res";
static const acitivtyRequest = "pangea.activity_req";
static const activityRecord = "pangea.activity_completion";
}

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'dart:math';
@ -12,6 +13,8 @@ import 'package:fluffychat/pangea/controllers/local_settings.dart';
import 'package:fluffychat/pangea/controllers/message_data_controller.dart';
import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/permissions_controller.dart';
import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart';
import 'package:fluffychat/pangea/controllers/practice_activity_record_controller.dart';
import 'package:fluffychat/pangea/controllers/speech_to_text_controller.dart';
import 'package:fluffychat/pangea/controllers/subscription_controller.dart';
import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart';
@ -53,6 +56,8 @@ class PangeaController {
late TextToSpeechController textToSpeech;
late SpeechToTextController speechToText;
late LanguageDetectionController languageDetection;
late PracticeActivityRecordController activityRecordController;
late PracticeGenerationController practiceGenerationController;
///store Services
late PLocalStore pStoreService;
@ -101,6 +106,8 @@ class PangeaController {
textToSpeech = TextToSpeechController(this);
speechToText = SpeechToTextController(this);
languageDetection = LanguageDetectionController(this);
activityRecordController = PracticeActivityRecordController(this);
practiceGenerationController = PracticeGenerationController();
PAuthGaurd.pController = this;
}

@ -0,0 +1,102 @@
import 'dart:async';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/enum/activity_type_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_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.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:matrix/matrix.dart';
/// Represents an item in the completion cache.
class _RequestCacheItem {
PracticeActivityRequest req;
Future<PracticeActivityEvent?> practiceActivityEvent;
_RequestCacheItem({
required this.req,
required this.practiceActivityEvent,
});
}
/// Controller for handling activity completions.
class PracticeGenerationController {
static final Map<int, _RequestCacheItem> _cache = {};
Timer? _cacheClearTimer;
PracticeGenerationController() {
_initializeCacheClearing();
}
void _initializeCacheClearing() {
const duration = Duration(minutes: 2);
_cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache());
}
void _clearCache() {
_cache.clear();
}
void dispose() {
_cacheClearTimer?.cancel();
}
Future<PracticeActivityEvent?> _sendAndPackageEvent(
PracticeActivityModel model,
PangeaMessageEvent pangeaMessageEvent,
) async {
final Event? activityEvent = await pangeaMessageEvent.room.sendPangeaEvent(
content: model.toJson(),
parentEventId: pangeaMessageEvent.eventId,
type: PangeaEventTypes.pangeaActivityRes,
);
if (activityEvent == null) {
return null;
}
return PracticeActivityEvent(
event: activityEvent,
timeline: pangeaMessageEvent.timeline,
);
}
Future<PracticeActivityEvent?> getPracticeActivity(
PracticeActivityRequest req,
PangeaMessageEvent event,
) async {
final int cacheKey = req.hashCode;
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey]!.practiceActivityEvent;
} else {
//TODO - send request to server/bot, either via API or via event of type pangeaActivityReq
// for now, just make and send the event from the client
final Future<PracticeActivityEvent?> eventFuture =
_sendAndPackageEvent(dummyModel(event), event);
_cache[cacheKey] =
_RequestCacheItem(req: req, practiceActivityEvent: eventFuture);
return _cache[cacheKey]!.practiceActivityEvent;
}
}
PracticeActivityModel dummyModel(PangeaMessageEvent event) =>
PracticeActivityModel(
tgtConstructs: [
ConstructIdentifier(lemma: "be", type: ConstructType.vocab),
],
activityType: ActivityTypeEnum.multipleChoice,
langCode: event.messageDisplayLangCode,
msgId: event.eventId,
multipleChoice: MultipleChoice(
question: "What is a synonym for 'happy'?",
choices: ["sad", "angry", "joyful", "tired"],
answer: "joyful",
),
);
}

@ -0,0 +1,94 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
/// Represents an item in the completion cache.
class _RecordCacheItem {
PracticeActivityRecordModel data;
Future<Event?> recordEvent;
_RecordCacheItem({required this.data, required this.recordEvent});
}
/// Controller for handling activity completions.
class PracticeActivityRecordController {
static final Map<int, _RecordCacheItem> _cache = {};
late final PangeaController _pangeaController;
Timer? _cacheClearTimer;
PracticeActivityRecordController(this._pangeaController) {
_initializeCacheClearing();
}
void _initializeCacheClearing() {
const duration = Duration(minutes: 2);
_cacheClearTimer = Timer.periodic(duration, (Timer t) => _clearCache());
}
void _clearCache() {
_cache.clear();
}
void dispose() {
_cacheClearTimer?.cancel();
}
/// Sends a practice activity record to the server and returns the corresponding event.
///
/// The [recordModel] parameter is the model representing the practice activity record.
/// The [practiceActivityEvent] parameter is the event associated with the practice activity.
/// Note that the system will send a new event if the model has changed in any way ie it is
/// a new completion of the practice activity. However, it will cache previous sends to ensure
/// that opening and closing of the widget does not result in multiple sends of the same data.
/// It allows checks the data to make sure that it contains responses to the practice activity
/// and does not represent a blank record with no actual completion to be saved.
///
/// Returns a [Future] that completes with the corresponding [Event] object.
Future<Event?> send(
PracticeActivityRecordModel recordModel,
PracticeActivityEvent practiceActivityEvent,
) async {
final int cacheKey = recordModel.hashCode;
if (recordModel.responses.isEmpty) {
return null;
}
if (_cache.containsKey(cacheKey)) {
return _cache[cacheKey]!.recordEvent;
} else {
final Future<Event?> eventFuture = practiceActivityEvent.event.room
.sendPangeaEvent(
content: recordModel.toJson(),
parentEventId: practiceActivityEvent.event.eventId,
type: PangeaEventTypes.activityRecord,
)
.catchError((e) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: StackTrace.current,
data: {
'recordModel': recordModel.toJson(),
'practiceActivityEvent': practiceActivityEvent.event.toJson(),
},
);
return null;
});
_cache[cacheKey] =
_RecordCacheItem(data: recordModel, recordEvent: eventFuture);
return _cache[cacheKey]!.recordEvent;
}
}
}

@ -0,0 +1,16 @@
enum ActivityTypeEnum { multipleChoice, freeResponse, listening, speaking }
extension ActivityTypeExtension on ActivityTypeEnum {
String get string {
switch (this) {
case ActivityTypeEnum.multipleChoice:
return 'multiple_choice';
case ActivityTypeEnum.freeResponse:
return 'free_response';
case ActivityTypeEnum.listening:
return 'listening';
case ActivityTypeEnum.speaking:
return 'speaking';
}
}
}

@ -0,0 +1,43 @@
// assistance state is, user has not typed a message, user has typed a message and IGC has not run,
// IGC is running, IGC has run and there are remaining steps (either IT or IGC), or all steps are done
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constants/colors.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
enum AssistanceState {
noMessage,
notFetched,
fetching,
fetched,
complete,
}
extension AssistanceStateExtension on AssistanceState {
Color stateColor(context) {
switch (this) {
case AssistanceState.noMessage:
case AssistanceState.notFetched:
case AssistanceState.fetching:
return Theme.of(context).colorScheme.primary;
case AssistanceState.fetched:
return PangeaColors.igcError;
case AssistanceState.complete:
return AppConfig.success;
}
}
String tooltip(L10n l10n) {
switch (this) {
case AssistanceState.noMessage:
case AssistanceState.notFetched:
return l10n.runGrammarCorrection;
case AssistanceState.fetching:
return "";
case AssistanceState.fetched:
return l10n.grammarCorrectionFailed;
case AssistanceState.complete:
return l10n.grammarCorrectionComplete;
}
}
}

@ -1,9 +1,16 @@
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:flutter/material.dart';
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 +24,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 +41,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 +59,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 +71,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:
@ -66,4 +80,29 @@ extension MessageModeExtension on MessageMode {
return true;
}
}
Color? iconColor(
PangeaMessageEvent event,
MessageMode? currentMode,
BuildContext context,
) {
final bool isPracticeActivity = this == MessageMode.practiceActivity;
final bool practicing = currentMode == MessageMode.practiceActivity;
final bool practiceEnabled = event.hasUncompletedActivity;
// if this is the practice activity icon, and there's no practice activities available,
// and the current mode is not practice, return lower opacity color.
if (isPracticeActivity && !practicing && !practiceEnabled) {
return Theme.of(context).iconTheme.color?.withOpacity(0.5);
}
// if this is not a practice activity icon, and practice activities are available,
// then return lower opacity color if the current mode is practice.
if (!isPracticeActivity && practicing && practiceEnabled) {
return Theme.of(context).iconTheme.color?.withOpacity(0.5);
}
// if this is the current mode, return primary color.
return currentMode == this ? Theme.of(context).colorScheme.primary : null;
}
}

@ -2,6 +2,8 @@ import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:flutter/foundation.dart';
@ -26,7 +28,12 @@ extension PangeaEvent on Event {
return PangeaRepresentation.fromJson(json) as V;
case PangeaEventTypes.choreoRecord:
return ChoreoRecord.fromJson(json) as V;
case PangeaEventTypes.pangeaActivityRes:
return PracticeActivityModel.fromJson(json) as V;
case PangeaEventTypes.activityRecord:
return PracticeActivityRecordModel.fromJson(json) as V;
default:
debugger(when: kDebugMode);
throw Exception("$type events do not have pangea content");
}
}

@ -1,9 +0,0 @@
// relates to a pangea representation event
// the matrix even fits the form of a regular matrix audio event
// but with something to distinguish it as a pangea audio event
import 'package:matrix/matrix.dart';
class PangeaAudioEvent {
Event? _event;
}

@ -1,3 +1,5 @@
import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
@ -23,7 +25,7 @@ class ChoreoEvent {
_content ??= event.getPangeaContent<ChoreoRecord>();
return _content;
} catch (err, s) {
if (kDebugMode) rethrow;
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
return null;
}

@ -1,4 +1,5 @@
import 'dart:convert';
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
@ -6,6 +7,7 @@ import 'package:fluffychat/pangea/controllers/text_to_speech_controller.dart';
import 'package:fluffychat/pangea/enum/audio_encoding_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/pangea_match_model.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
@ -14,6 +16,7 @@ import 'package:fluffychat/pangea/models/speech_to_text_models.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
@ -549,12 +552,42 @@ class PangeaMessageEvent {
_event.messageType != PangeaEventTypes.report &&
_event.messageType == MessageTypes.Text;
// this is just showActivityIcon now but will include
// logic for showing
bool get showMessageButtons => hasUncompletedActivity;
/// Returns a boolean value indicating whether to show an activity icon for this message event.
///
/// The [hasUncompletedActivity] getter checks if the [l2Code] is null, and if so, returns false.
/// Otherwise, it retrieves a list of [PracticeActivityEvent] objects using the [practiceActivities] function
/// with the [l2Code] as an argument.
/// If the list is empty, it returns false.
/// Otherwise, it checks if every activity in the list is complete using the [isComplete] property.
/// If any activity is not complete, it returns true, indicating that the activity icon should be shown.
/// Otherwise, it returns false.
bool get hasUncompletedActivity {
if (l2Code == null) return false;
final List<PracticeActivityEvent> activities = practiceActivities(l2Code!);
if (activities.isEmpty) return false;
// for now, only show the button if the event has no completed activities
// TODO - revert this after adding logic to show next activity
for (final activity in activities) {
if (activity.isComplete) return false;
}
return true;
// if (activities.isEmpty) return false;
// return !activities.every((activity) => activity.isComplete);
}
String? get l2Code =>
MatrixState.pangeaController.languageController.activeL2Code();
String get messageDisplayLangCode {
final bool immersionMode = MatrixState
.pangeaController.permissionsController
.isToolEnabled(ToolSetting.immersionMode, room);
final String? l2Code =
MatrixState.pangeaController.languageController.activeL2Code();
final String? originalLangCode =
(originalWritten ?? originalSent)?.langCode;
@ -578,6 +611,53 @@ class PangeaMessageEvent {
return steps;
}
List<PracticeActivityEvent> get _practiceActivityEvents => _latestEdit
.aggregatedEvents(
timeline,
PangeaEventTypes.pangeaActivityRes,
)
.map(
(e) => PracticeActivityEvent(
timeline: timeline,
event: e,
),
)
.toList();
bool get hasActivities {
try {
final String? l2code =
MatrixState.pangeaController.languageController.activeL2Code();
if (l2code == null) return false;
return practiceActivities(l2code).isNotEmpty;
} catch (e, s) {
ErrorHandler.logError(e: e, s: s);
return false;
}
}
List<PracticeActivityEvent> practiceActivities(
String langCode, {
bool debug = false,
}) {
try {
debugger(when: debug);
final List<PracticeActivityEvent> activities = [];
for (final event in _practiceActivityEvents) {
if (event.practiceActivity.langCode == langCode) {
activities.add(event);
}
}
return activities;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s, data: event.toJson());
return [];
}
}
// 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

@ -1,6 +1,9 @@
import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
@ -22,6 +25,7 @@ class TokensEvent {
_content ??= event.getPangeaContent<PangeaMessageTokens>();
return _content!;
} catch (err, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
return null;
}

@ -0,0 +1,24 @@
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class PracticeActivityRecordEvent {
Event event;
PracticeActivityRecordModel? _content;
PracticeActivityRecordEvent({required this.event}) {
if (event.type != PangeaEventTypes.activityRecord) {
throw Exception(
"${event.type} should not be used to make a PracticeActivityRecordEvent",
);
}
}
PracticeActivityRecordModel? get record {
_content ??= event.getPangeaContent<PracticeActivityRecordModel>();
return _content!;
}
}

@ -0,0 +1,67 @@
import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class PracticeActivityEvent {
Event event;
Timeline? timeline;
PracticeActivityModel? _content;
PracticeActivityEvent({
required this.event,
required this.timeline,
content,
}) {
if (content != null) {
if (!kDebugMode) {
throw Exception(
"content should not be set on product, just a dev placeholder",
);
} else {
_content = content;
}
}
if (event.type != PangeaEventTypes.pangeaActivityRes) {
throw Exception(
"${event.type} should not be used to make a PracticeActivityEvent",
);
}
}
PracticeActivityModel get practiceActivity {
_content ??= event.getPangeaContent<PracticeActivityModel>();
return _content!;
}
//in aggregatedEvents for the event, find all practiceActivityRecordEvents whose sender matches the client's userId
List<PracticeActivityRecordEvent> get allRecords {
if (timeline == null) {
debugger(when: kDebugMode);
return [];
}
final List<Event> records = event
.aggregatedEvents(timeline!, PangeaEventTypes.activityRecord)
.toList();
return records
.map((event) => PracticeActivityRecordEvent(event: event))
.toList();
}
List<PracticeActivityRecordEvent> get userRecords => allRecords
.where(
(recordEvent) =>
recordEvent.event.senderId == recordEvent.event.room.client.userID,
)
.toList();
/// Checks if there are any user records in the list for this activity,
/// and, if so, then the activity is complete
bool get isComplete => userRecords.isNotEmpty;
}

@ -0,0 +1,41 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:flutter/material.dart';
class MultipleChoice {
final String question;
final List<String> choices;
final String answer;
MultipleChoice({
required this.question,
required this.choices,
required this.answer,
});
bool isCorrect(int index) => index == correctAnswerIndex;
bool get isValidQuestion => choices.contains(answer);
int get correctAnswerIndex => choices.indexOf(answer);
int choiceIndex(String choice) => choices.indexOf(choice);
Color choiceColor(int index) =>
index == correctAnswerIndex ? AppConfig.success : AppConfig.warning;
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(),
answer: json['answer'] as String,
);
}
Map<String, dynamic> toJson() {
return {
'question': question,
'choices': choices,
'answer': answer,
};
}
}

@ -0,0 +1,280 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
class ConstructIdentifier {
final String lemma;
final ConstructType type;
ConstructIdentifier({required this.lemma, required this.type});
factory ConstructIdentifier.fromJson(Map<String, dynamic> json) {
try {
return ConstructIdentifier(
lemma: json['lemma'] as String,
type: ConstructType.values.firstWhere(
(e) => e.string == json['type'],
),
);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s, data: json);
rethrow;
}
}
Map<String, dynamic> toJson() {
return {
'lemma': lemma,
'type': type.string,
};
}
}
class CandidateMessage {
final String msgId;
final String roomId;
final String text;
CandidateMessage({
required this.msgId,
required this.roomId,
required this.text,
});
factory CandidateMessage.fromJson(Map<String, dynamic> json) {
return CandidateMessage(
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,
};
}
}
enum PracticeActivityMode { focus, srs }
extension on PracticeActivityMode {
String get value {
switch (this) {
case PracticeActivityMode.focus:
return 'focus';
case PracticeActivityMode.srs:
return 'srs';
}
}
}
class PracticeActivityRequest {
final PracticeActivityMode? mode;
final List<ConstructIdentifier>? targetConstructs;
final List<CandidateMessage>? candidateMessages;
final List<String>? userIds;
final ActivityTypeEnum? activityType;
final int? numActivities;
PracticeActivityRequest({
this.mode,
this.targetConstructs,
this.candidateMessages,
this.userIds,
this.activityType,
this.numActivities,
});
factory PracticeActivityRequest.fromJson(Map<String, dynamic> json) {
return PracticeActivityRequest(
mode: PracticeActivityMode.values.firstWhere(
(e) => e.value == json['mode'],
),
targetConstructs: (json['target_constructs'] as List?)
?.map((e) => ConstructIdentifier.fromJson(e as Map<String, dynamic>))
.toList(),
candidateMessages: (json['candidate_msgs'] as List)
.map((e) => CandidateMessage.fromJson(e as Map<String, dynamic>))
.toList(),
userIds: (json['user_ids'] as List?)?.map((e) => e as String).toList(),
activityType: ActivityTypeEnum.values.firstWhere(
(e) => e.toString().split('.').last == json['activity_type'],
),
numActivities: json['num_activities'] as int,
);
}
Map<String, dynamic> toJson() {
return {
'mode': mode?.value,
'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,
};
}
// override operator == and hashCode
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PracticeActivityRequest &&
other.mode == mode &&
other.targetConstructs == targetConstructs &&
other.candidateMessages == candidateMessages &&
other.userIds == userIds &&
other.activityType == activityType &&
other.numActivities == numActivities;
}
@override
int get hashCode {
return mode.hashCode ^
targetConstructs.hashCode ^
candidateMessages.hashCode ^
userIds.hashCode ^
activityType.hashCode ^
numActivities.hashCode;
}
}
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 ActivityTypeEnum 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: ActivityTypeEnum.values.firstWhere(
(e) => e.string == 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(),
};
}
}

@ -0,0 +1,137 @@
// 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
import 'dart:developer';
import 'dart:typed_data';
class PracticeActivityRecordModel {
final String? question;
late List<ActivityResponse> responses;
PracticeActivityRecordModel({
required this.question,
List<ActivityResponse>? responses,
}) {
if (responses == null) {
this.responses = List<ActivityResponse>.empty(growable: true);
} else {
this.responses = responses;
}
}
factory PracticeActivityRecordModel.fromJson(
Map<String, dynamic> json,
) {
return PracticeActivityRecordModel(
question: json['question'] as String,
responses: (json['responses'] as List)
.map((e) => ActivityResponse.fromJson(e as Map<String, dynamic>))
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
'question': question,
'responses': responses.map((e) => e.toJson()).toList(),
};
}
/// get the latest response index according to the response timeStamp
/// sort the responses by timestamp and get the index of the last response
String? get latestResponse {
if (responses.isEmpty) {
return null;
}
responses.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return responses[responses.length - 1].text;
}
void addResponse({
String? text,
Uint8List? audioBytes,
Uint8List? imageBytes,
}) {
try {
responses.add(
ActivityResponse(
text: text,
audioBytes: audioBytes,
imageBytes: imageBytes,
timestamp: DateTime.now(),
),
);
} catch (e) {
debugger();
}
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PracticeActivityRecordModel &&
other.question == question &&
other.responses.length == responses.length &&
List.generate(
responses.length,
(index) => responses[index] == other.responses[index],
).every((element) => element);
}
@override
int get hashCode => question.hashCode ^ responses.hashCode;
}
class ActivityResponse {
// the user's response
// has nullable string, nullable audio bytes, nullable image bytes, and timestamp
final String? text;
final Uint8List? audioBytes;
final Uint8List? imageBytes;
final DateTime timestamp;
ActivityResponse({
this.text,
this.audioBytes,
this.imageBytes,
required this.timestamp,
});
factory ActivityResponse.fromJson(Map<String, dynamic> json) {
return ActivityResponse(
text: json['text'] as String?,
audioBytes: json['audio'] as Uint8List?,
imageBytes: json['image'] as Uint8List?,
timestamp: DateTime.parse(json['timestamp'] as String),
);
}
Map<String, dynamic> toJson() {
return {
'text': text,
'audio': audioBytes,
'image': imageBytes,
'timestamp': timestamp.toIso8601String(),
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ActivityResponse &&
other.text == text &&
other.audioBytes == audioBytes &&
other.imageBytes == imageBytes &&
other.timestamp == timestamp;
}
@override
int get hashCode =>
text.hashCode ^
audioBytes.hashCode ^
imageBytes.hashCode ^
timestamp.hashCode;
}

@ -0,0 +1,96 @@
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:flutter/material.dart';
class MessageButtons extends StatelessWidget {
final ToolbarDisplayController? toolbarController;
const MessageButtons({
super.key,
this.toolbarController,
});
void showActivity(BuildContext context) {
toolbarController?.showToolbar(
context,
mode: MessageMode.practiceActivity,
);
}
@override
Widget build(BuildContext context) {
if (toolbarController == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Row(
children: [
HoverIconButton(
icon: MessageMode.practiceActivity.icon,
onTap: () => showActivity(context),
primaryColor: Theme.of(context).colorScheme.primary,
tooltip: MessageMode.practiceActivity.tooltip(context),
),
// Additional buttons can be added here in the future
],
),
);
}
}
class HoverIconButton extends StatefulWidget {
final IconData icon;
final VoidCallback onTap;
final Color primaryColor;
final String tooltip;
const HoverIconButton({
super.key,
required this.icon,
required this.onTap,
required this.primaryColor,
required this.tooltip,
});
@override
_HoverIconButtonState createState() => _HoverIconButtonState();
}
class _HoverIconButtonState extends State<HoverIconButton> {
bool _isHovered = false;
@override
Widget build(BuildContext context) {
return Tooltip(
message: widget.tooltip,
child: InkWell(
onTap: widget.onTap,
onHover: (hovering) {
setState(() => _isHovered = hovering);
},
borderRadius: BorderRadius.circular(100),
child: Container(
decoration: BoxDecoration(
color: _isHovered ? widget.primaryColor : null,
borderRadius: BorderRadius.circular(100),
border: Border.all(
width: 1,
color: widget.primaryColor,
),
),
padding: const EdgeInsets.all(2),
child: Icon(
widget.icon,
size: 18,
// when hovered, use themeData to get background color, otherwise use primary
color: _isHovered
? Theme.of(context).scaffoldBackgroundColor
: widget.primaryColor,
),
),
),
);
}
}

@ -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/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';
@ -202,6 +203,12 @@ class MessageToolbarState extends State<MessageToolbar> {
return;
}
// if there is an uncompleted activity, then show that
// we don't want the user to user the tools to get the answer :P
if (widget.pangeaMessageEvent.hasUncompletedActivity) {
newMode = MessageMode.practiceActivity;
}
if (mounted) {
setState(() {
currentMode = newMode;
@ -229,6 +236,9 @@ class MessageToolbarState extends State<MessageToolbar> {
case MessageMode.definition:
showDefinition();
break;
case MessageMode.practiceActivity:
showPracticeActivity();
break;
default:
ErrorHandler.logError(
e: "Invalid toolbar mode",
@ -286,6 +296,13 @@ class MessageToolbarState extends State<MessageToolbar> {
);
}
void showPracticeActivity() {
toolbarContent = PracticeActivityCard(
pangeaMessageEvent: widget.pangeaMessageEvent,
controller: this,
);
}
void showImage() {}
void spellCheck() {}
@ -403,9 +420,11 @@ class MessageToolbarState extends State<MessageToolbar> {
message: mode.tooltip(context),
child: IconButton(
icon: Icon(mode.icon),
color: currentMode == mode
? Theme.of(context).colorScheme.primary
: null,
color: mode.iconColor(
widget.pangeaMessageEvent,
currentMode,
context,
),
onPressed: () => updateMode(mode),
),
);

@ -0,0 +1,61 @@
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class GeneratePracticeActivityButton extends StatelessWidget {
final PangeaMessageEvent pangeaMessageEvent;
final Function(PracticeActivityEvent?) onActivityGenerated;
const GeneratePracticeActivityButton({
super.key,
required this.pangeaMessageEvent,
required this.onActivityGenerated,
});
//TODO - probably disable the generation of activities for specific messages
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () async {
final String? l2Code = MatrixState.pangeaController.languageController
.activeL1Model()
?.langCode;
if (l2Code == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(L10n.of(context)!.noLanguagesSet),
),
);
return;
}
final PracticeActivityEvent? practiceActivityEvent = await MatrixState
.pangeaController.practiceGenerationController
.getPracticeActivity(
PracticeActivityRequest(
candidateMessages: [
CandidateMessage(
msgId: pangeaMessageEvent.eventId,
roomId: pangeaMessageEvent.room.id,
text:
pangeaMessageEvent.representationByLanguage(l2Code)?.text ??
pangeaMessageEvent.body,
),
],
userIds: pangeaMessageEvent.room.client.userID != null
? [pangeaMessageEvent.room.client.userID!]
: null,
),
pangeaMessageEvent,
);
onActivityGenerated(practiceActivityEvent);
},
child: Text(L10n.of(context)!.practice),
);
}
}

@ -0,0 +1,69 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/choreographer/widgets/choice_array.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_content.dart';
import 'package:flutter/material.dart';
class MultipleChoiceActivity extends StatelessWidget {
final MessagePracticeActivityContentState card;
final Function(int) updateChoice;
final bool isActive;
const MultipleChoiceActivity({
super.key,
required this.card,
required this.updateChoice,
required this.isActive,
});
PracticeActivityEvent get practiceEvent => card.practiceEvent;
int? get selectedChoiceIndex => card.selectedChoiceIndex;
bool get submitted => card.recordSubmittedThisSession;
@override
Widget build(BuildContext context) {
final PracticeActivityModel practiceActivity =
practiceEvent.practiceActivity;
return Container(
padding: const EdgeInsets.all(8),
child: Column(
children: [
Text(
practiceActivity.multipleChoice!.question,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
ChoicesArray(
isLoading: false,
uniqueKeyForLayerLink: (index) => "multiple_choice_$index",
originalSpan: "placeholder",
onPressed: updateChoice,
selectedChoiceIndex: selectedChoiceIndex,
choices: practiceActivity.multipleChoice!.choices
.mapIndexed(
(index, value) => Choice(
text: value,
color: (selectedChoiceIndex == index ||
practiceActivity.multipleChoice!
.isCorrect(index)) &&
submitted
? practiceActivity.multipleChoice!.choiceColor(index)
: null,
isGold: practiceActivity.multipleChoice!.isCorrect(index),
),
)
.toList(),
isActive: isActive,
),
],
),
);
}
}

@ -0,0 +1,108 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/utils/bot_style.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_content.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class PracticeActivityCard extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageToolbarState controller;
const PracticeActivityCard({
super.key,
required this.pangeaMessageEvent,
required this.controller,
});
@override
MessagePracticeActivityCardState createState() =>
MessagePracticeActivityCardState();
}
class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
PracticeActivityEvent? practiceEvent;
@override
void initState() {
super.initState();
loadInitialData();
}
String? get langCode {
final String? langCode = MatrixState.pangeaController.languageController
.activeL2Model()
?.langCode;
if (langCode == null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(L10n.of(context)!.noLanguagesSet)),
);
debugger(when: kDebugMode);
return null;
}
return langCode;
}
void loadInitialData() {
if (langCode == null) return;
updatePracticeActivity();
if (practiceEvent == null) {
debugger(when: kDebugMode);
}
}
void updatePracticeActivity() {
if (langCode == null) return;
final List<PracticeActivityEvent> activities =
widget.pangeaMessageEvent.practiceActivities(langCode!);
if (activities.isEmpty) return;
final List<PracticeActivityEvent> incompleteActivities =
activities.where((element) => !element.isComplete).toList();
debugPrint("total events: ${activities.length}");
debugPrint("incomplete practice events: ${incompleteActivities.length}");
// TODO update to show next activity
practiceEvent = activities.first;
// // if an incomplete activity is found, show that
// if (incompleteActivities.isNotEmpty) {
// practiceEvent = incompleteActivities.first;
// }
// // if no incomplete activity is found, show the last activity
// else if (activities.isNotEmpty) {
// practiceEvent = activities.last;
// }
setState(() {});
}
void showNextActivity() {
if (langCode == null) return;
updatePracticeActivity();
widget.controller.updateMode(MessageMode.practiceActivity);
}
@override
Widget build(BuildContext context) {
if (practiceEvent == null) {
return Text(
L10n.of(context)!.noActivitiesFound,
style: BotStyle.text(context),
);
// return GeneratePracticeActivityButton(
// pangeaMessageEvent: widget.pangeaMessageEvent,
// onActivityGenerated: updatePracticeActivity,
// );
}
return PracticeActivityContent(
practiceEvent: practiceEvent!,
pangeaMessageEvent: widget.pangeaMessageEvent,
controller: this,
);
}
}

@ -0,0 +1,165 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/practice_activity_card.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class PracticeActivityContent extends StatefulWidget {
final PracticeActivityEvent practiceEvent;
final PangeaMessageEvent pangeaMessageEvent;
final MessagePracticeActivityCardState controller;
const PracticeActivityContent({
super.key,
required this.practiceEvent,
required this.pangeaMessageEvent,
required this.controller,
});
@override
MessagePracticeActivityContentState createState() =>
MessagePracticeActivityContentState();
}
class MessagePracticeActivityContentState
extends State<PracticeActivityContent> {
int? selectedChoiceIndex;
PracticeActivityRecordModel? recordModel;
bool recordSubmittedThisSession = false;
bool recordSubmittedPreviousSession = false;
PracticeActivityEvent get practiceEvent => widget.practiceEvent;
@override
void initState() {
super.initState();
initalizeActivity();
}
@override
void didUpdateWidget(covariant PracticeActivityContent oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.practiceEvent.event.eventId !=
widget.practiceEvent.event.eventId) {
initalizeActivity();
}
}
void initalizeActivity() {
final PracticeActivityRecordEvent? recordEvent =
widget.practiceEvent.userRecords.firstOrNull;
if (recordEvent?.record == null) {
recordModel = PracticeActivityRecordModel(
question:
widget.practiceEvent.practiceActivity.multipleChoice!.question,
);
} else {
recordModel = recordEvent!.record;
//Note that only MultipleChoice activities will have this so we probably should move this logic to the MultipleChoiceActivity widget
selectedChoiceIndex = recordModel?.latestResponse != null
? widget.practiceEvent.practiceActivity.multipleChoice
?.choiceIndex(recordModel!.latestResponse!)
: null;
recordSubmittedPreviousSession = true;
recordSubmittedThisSession = true;
}
setState(() {});
}
void updateChoice(int index) {
setState(() {
selectedChoiceIndex = index;
recordModel!.addResponse(
text: widget
.practiceEvent.practiceActivity.multipleChoice!.choices[index],
);
});
}
Widget get activityWidget {
switch (widget.practiceEvent.practiceActivity.activityType) {
case ActivityTypeEnum.multipleChoice:
return MultipleChoiceActivity(
card: this,
updateChoice: updateChoice,
isActive:
!recordSubmittedPreviousSession && !recordSubmittedThisSession,
);
default:
return const SizedBox.shrink();
}
}
void sendRecord() {
MatrixState.pangeaController.activityRecordController
.send(
recordModel!,
widget.practiceEvent,
)
.catchError((error) {
ErrorHandler.logError(
e: error,
s: StackTrace.current,
data: {
'recordModel': recordModel?.toJson(),
'practiceEvent': widget.practiceEvent.event.toJson(),
},
);
return null;
}).then((_) => widget.controller.showNextActivity());
setState(() {
recordSubmittedThisSession = true;
});
}
@override
Widget build(BuildContext context) {
debugPrint(
"MessagePracticeActivityContentState.build with selectedChoiceIndex: $selectedChoiceIndex",
);
return Column(
children: [
activityWidget,
const SizedBox(height: 8),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Opacity(
opacity: selectedChoiceIndex != null &&
!recordSubmittedThisSession &&
!recordSubmittedPreviousSession
? 1.0
: 0.5,
child: TextButton(
onPressed: () {
if (recordSubmittedThisSession ||
recordSubmittedPreviousSession) {
return;
}
selectedChoiceIndex != null ? sendRecord() : null;
},
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all<Color>(
AppConfig.primaryColor,
),
),
child: Text(L10n.of(context)!.submit),
),
),
],
),
],
);
}
}

@ -98,7 +98,6 @@ pLanguageDialog(BuildContext parentContext, Function callback) async {
Navigator.pop(context);
} catch (err, s) {
debugger(when: kDebugMode);
//PTODO-Lala add standard error message
ErrorHandler.logError(e: err, s: s);
rethrow;
} finally {

@ -860,7 +860,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"be": [
@ -2357,7 +2360,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"bn": [
@ -3850,7 +3856,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"bo": [
@ -5347,7 +5356,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ca": [
@ -6246,7 +6258,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"cs": [
@ -7227,7 +7242,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"de": [
@ -8091,7 +8109,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"el": [
@ -9539,7 +9560,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"eo": [
@ -10685,7 +10709,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"es": [
@ -10697,7 +10724,10 @@
"addConversationBotButtonRemove",
"addConversationBotDialogRemoveConfirmation",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"et": [
@ -11561,7 +11591,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"eu": [
@ -12427,7 +12460,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"fa": [
@ -13430,7 +13466,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"fi": [
@ -14397,7 +14436,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"fil": [
@ -15720,7 +15762,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"fr": [
@ -16722,7 +16767,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ga": [
@ -17853,7 +17901,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"gl": [
@ -18717,7 +18768,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"he": [
@ -19967,7 +20021,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"hi": [
@ -21457,7 +21514,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"hr": [
@ -22400,7 +22460,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"hu": [
@ -23280,7 +23343,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ia": [
@ -24763,7 +24829,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"id": [
@ -25633,7 +25702,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ie": [
@ -26887,7 +26959,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"it": [
@ -27808,7 +27883,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ja": [
@ -28840,7 +28918,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ka": [
@ -30191,7 +30272,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ko": [
@ -31057,7 +31141,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"lt": [
@ -32089,7 +32176,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"lv": [
@ -32961,7 +33051,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"nb": [
@ -34157,7 +34250,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"nl": [
@ -35117,7 +35213,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"pl": [
@ -36086,7 +36185,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"pt": [
@ -37561,7 +37663,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"pt_BR": [
@ -38431,7 +38536,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"pt_PT": [
@ -39628,7 +39736,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ro": [
@ -40632,7 +40743,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ru": [
@ -41502,7 +41616,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"sk": [
@ -42765,7 +42882,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"sl": [
@ -44158,7 +44278,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"sr": [
@ -45325,7 +45448,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"sv": [
@ -46226,7 +46352,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"ta": [
@ -47720,7 +47849,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"th": [
@ -49168,7 +49300,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"tr": [
@ -50032,7 +50167,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"uk": [
@ -50933,7 +51071,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"vi": [
@ -52282,7 +52423,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"zh": [
@ -53146,7 +53290,10 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
],
"zh_Hant": [
@ -54291,6 +54438,9 @@
"spaceAnalytics",
"changeAnalyticsLanguage",
"suggestToSpace",
"suggestToSpaceDesc"
"suggestToSpaceDesc",
"practice",
"noLanguagesSet",
"noActivitiesFound"
]
}

Loading…
Cancel
Save