diff --git a/README.md b/README.md
index 7c27b6e2e..a1ad9f2b7 100644
--- a/README.md
+++ b/README.md
@@ -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)
* Fabiyamada is a graphics designer and has made the fluffychat logo and the banner. Big thanks for her great designs.
diff --git a/assets/l10n/intl_en.arb b/assets/l10n/intl_en.arb
index 4a27fd6da..cb319c9c7 100644
--- a/assets/l10n/intl_en.arb
+++ b/assets/l10n/intl_en.arb
@@ -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"
}
\ No newline at end of file
diff --git a/assets/l10n/intl_es.arb b/assets/l10n/intl_es.arb
index 0658bff50..dc328294a 100644
--- a/assets/l10n/intl_es.arb
+++ b/assets/l10n/intl_es.arb
@@ -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.",
diff --git a/lib/pages/chat/events/message.dart b/lib/pages/chat/events/message.dart
index dc129a192..c5756438c 100644
--- a/lib/pages/chat/events/message.dart
+++ b/lib/pages/chat/events/message.dart
@@ -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)
diff --git a/lib/pages/chat_list/chat_list.dart b/lib/pages/chat_list/chat_list.dart
index 9d7ff2440..569eba880 100644
--- a/lib/pages/chat_list/chat_list.dart
+++ b/lib/pages/chat_list/chat_list.dart
@@ -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
}
List 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? publicRoomsResponse;
diff --git a/lib/pages/chat_list/client_chooser_button.dart b/lib/pages/chat_list/client_chooser_button.dart
index e45b38c33..4ad107f6b 100644
--- a/lib/pages/chat_list/client_chooser_button.dart
+++ b/lib/pages/chat_list/client_chooser_button.dart
@@ -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: [
diff --git a/lib/pangea/choreographer/controllers/choreographer.dart b/lib/pangea/choreographer/controllers/choreographer.dart
index 6ea029a6f..a0c6a5f6b 100644
--- a/lib/pangea/choreographer/controllers/choreographer.dart
+++ b/lib/pangea/choreographer/controllers/choreographer.dart
@@ -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,
-}
diff --git a/lib/pangea/choreographer/widgets/choice_array.dart b/lib/pangea/choreographer/widgets/choice_array.dart
index 54fd601b9..c26fd706d 100644
--- a/lib/pangea/choreographer/widgets/choice_array.dart
+++ b/lib/pangea/choreographer/widgets/choice_array.dart
@@ -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(
+ backgroundColor: WidgetStateProperty.all(
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
);
_animation = widget.isGold
- ? Tween(begin: 1.0, end: 1.2).animate(_controller)
- : TweenSequence([
- TweenSequenceItem(
- tween: Tween(begin: 0, end: -8 * pi / 180),
- weight: 1.0,
- ),
- TweenSequenceItem(
- tween: Tween(begin: -8 * pi / 180, end: 16 * pi / 180),
- weight: 2.0,
- ),
- TweenSequenceItem(
- tween: Tween(begin: 16 * pi / 180, end: 0),
- weight: 1.0,
- ),
- ]).animate(_controller);
+ ? Tween(begin: 1.0, end: 1.2).animate(_controller)
+ : TweenSequence([
+ TweenSequenceItem(
+ tween: Tween(begin: 0, end: -8 * pi / 180),
+ weight: 1.0,
+ ),
+ TweenSequenceItem(
+ tween: Tween(begin: -8 * pi / 180, end: 16 * pi / 180),
+ weight: 2.0,
+ ),
+ TweenSequenceItem(
+ tween: Tween(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
@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
diff --git a/lib/pangea/choreographer/widgets/start_igc_button.dart b/lib/pangea/choreographer/widgets/start_igc_button.dart
index 49e4b078d..877158dd2 100644
--- a/lib/pangea/choreographer/widgets/start_igc_button.dart
+++ b/lib/pangea/choreographer/widgets/start_igc_button.dart
@@ -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
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
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
);
}
}
-
-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;
- }
- }
-}
diff --git a/lib/pangea/constants/pangea_event_types.dart b/lib/pangea/constants/pangea_event_types.dart
index 7451a2a70..27b177a35 100644
--- a/lib/pangea/constants/pangea_event_types.dart
+++ b/lib/pangea/constants/pangea_event_types.dart
@@ -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";
}
diff --git a/lib/pangea/controllers/pangea_controller.dart b/lib/pangea/controllers/pangea_controller.dart
index 1d1a00c05..7fbf91fd0 100644
--- a/lib/pangea/controllers/pangea_controller.dart
+++ b/lib/pangea/controllers/pangea_controller.dart
@@ -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;
}
diff --git a/lib/pangea/controllers/practice_activity_generation_controller.dart b/lib/pangea/controllers/practice_activity_generation_controller.dart
new file mode 100644
index 000000000..29047d0c4
--- /dev/null
+++ b/lib/pangea/controllers/practice_activity_generation_controller.dart
@@ -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;
+
+ _RequestCacheItem({
+ required this.req,
+ required this.practiceActivityEvent,
+ });
+}
+
+/// Controller for handling activity completions.
+class PracticeGenerationController {
+ static final Map _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 _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 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 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",
+ ),
+ );
+}
diff --git a/lib/pangea/controllers/practice_activity_record_controller.dart b/lib/pangea/controllers/practice_activity_record_controller.dart
new file mode 100644
index 000000000..b075fa553
--- /dev/null
+++ b/lib/pangea/controllers/practice_activity_record_controller.dart
@@ -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 recordEvent;
+
+ _RecordCacheItem({required this.data, required this.recordEvent});
+}
+
+/// Controller for handling activity completions.
+class PracticeActivityRecordController {
+ static final Map _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 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 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;
+ }
+ }
+}
diff --git a/lib/pangea/enum/activity_type_enum.dart b/lib/pangea/enum/activity_type_enum.dart
new file mode 100644
index 000000000..d429aa038
--- /dev/null
+++ b/lib/pangea/enum/activity_type_enum.dart
@@ -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';
+ }
+ }
+}
diff --git a/lib/pangea/enum/assistance_state_enum.dart b/lib/pangea/enum/assistance_state_enum.dart
new file mode 100644
index 000000000..6d3a853da
--- /dev/null
+++ b/lib/pangea/enum/assistance_state_enum.dart
@@ -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;
+ }
+ }
+}
diff --git a/lib/pangea/enum/message_mode_enum.dart b/lib/pangea/enum/message_mode_enum.dart
index 25948d23b..58753e5b5 100644
--- a/lib/pangea/enum/message_mode_enum.dart
+++ b/lib/pangea/enum/message_mode_enum.dart
@@ -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;
+ }
}
diff --git a/lib/pangea/extensions/pangea_event_extension.dart b/lib/pangea/extensions/pangea_event_extension.dart
index f62f27925..17e14ed86 100644
--- a/lib/pangea/extensions/pangea_event_extension.dart
+++ b/lib/pangea/extensions/pangea_event_extension.dart
@@ -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");
}
}
diff --git a/lib/pangea/matrix_event_wrappers/pangea_audio_events.dart b/lib/pangea/matrix_event_wrappers/pangea_audio_events.dart
deleted file mode 100644
index 3583d021e..000000000
--- a/lib/pangea/matrix_event_wrappers/pangea_audio_events.dart
+++ /dev/null
@@ -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;
-}
diff --git a/lib/pangea/matrix_event_wrappers/pangea_choreo_event.dart b/lib/pangea/matrix_event_wrappers/pangea_choreo_event.dart
index 47a6b688f..a6a79fd4f 100644
--- a/lib/pangea/matrix_event_wrappers/pangea_choreo_event.dart
+++ b/lib/pangea/matrix_event_wrappers/pangea_choreo_event.dart
@@ -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();
return _content;
} catch (err, s) {
- if (kDebugMode) rethrow;
+ debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
return null;
}
diff --git a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart
index b22aa7491..951b77dfc 100644
--- a/lib/pangea/matrix_event_wrappers/pangea_message_event.dart
+++ b/lib/pangea/matrix_event_wrappers/pangea_message_event.dart
@@ -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 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 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 practiceActivities(
+ String langCode, {
+ bool debug = false,
+ }) {
+ try {
+ debugger(when: debug);
+ final List 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 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/pangea_tokens_event.dart b/lib/pangea/matrix_event_wrappers/pangea_tokens_event.dart
index 0c138c637..f617b8dae 100644
--- a/lib/pangea/matrix_event_wrappers/pangea_tokens_event.dart
+++ b/lib/pangea/matrix_event_wrappers/pangea_tokens_event.dart
@@ -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();
return _content!;
} catch (err, s) {
+ debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
return null;
}
diff --git a/lib/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart b/lib/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart
new file mode 100644
index 000000000..d4b9cde23
--- /dev/null
+++ b/lib/pangea/matrix_event_wrappers/practice_acitivity_record_event.dart
@@ -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();
+ return _content!;
+ }
+}
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..c5f35be91
--- /dev/null
+++ b/lib/pangea/matrix_event_wrappers/practice_activity_event.dart
@@ -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();
+ return _content!;
+ }
+
+ //in aggregatedEvents for the event, find all practiceActivityRecordEvents whose sender matches the client's userId
+ List get allRecords {
+ if (timeline == null) {
+ debugger(when: kDebugMode);
+ return [];
+ }
+ final List records = event
+ .aggregatedEvents(timeline!, PangeaEventTypes.activityRecord)
+ .toList();
+
+ return records
+ .map((event) => PracticeActivityRecordEvent(event: event))
+ .toList();
+ }
+
+ List 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;
+}
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..3cd78a66a
--- /dev/null
+++ b/lib/pangea/models/practice_activities.dart/multiple_choice_activity_model.dart
@@ -0,0 +1,41 @@
+import 'package:fluffychat/config/app_config.dart';
+import 'package:flutter/material.dart';
+
+class MultipleChoice {
+ final String question;
+ final List 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 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 toJson() {
+ return {
+ 'question': question,
+ 'choices': choices,
+ 'answer': answer,
+ };
+ }
+}
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..ae8455c7f
--- /dev/null
+++ b/lib/pangea/models/practice_activities.dart/practice_activity_model.dart
@@ -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 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 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 json) {
+ return CandidateMessage(
+ 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,
+ };
+ }
+}
+
+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? targetConstructs;
+ final List? candidateMessages;
+ final List? userIds;
+ final ActivityTypeEnum? activityType;
+ final int? numActivities;
+
+ PracticeActivityRequest({
+ this.mode,
+ this.targetConstructs,
+ this.candidateMessages,
+ this.userIds,
+ this.activityType,
+ this.numActivities,
+ });
+
+ factory PracticeActivityRequest.fromJson(Map 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))
+ .toList(),
+ candidateMessages: (json['candidate_msgs'] as List)
+ .map((e) => CandidateMessage.fromJson(e as Map))
+ .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 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 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 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 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: ActivityTypeEnum.values.firstWhere(
+ (e) => e.string == 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/models/practice_activities.dart/practice_activity_record_model.dart b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart
new file mode 100644
index 000000000..3fe3e859d
--- /dev/null
+++ b/lib/pangea/models/practice_activities.dart/practice_activity_record_model.dart
@@ -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 responses;
+
+ PracticeActivityRecordModel({
+ required this.question,
+ List? responses,
+ }) {
+ if (responses == null) {
+ this.responses = List.empty(growable: true);
+ } else {
+ this.responses = responses;
+ }
+ }
+
+ factory PracticeActivityRecordModel.fromJson(
+ Map json,
+ ) {
+ return PracticeActivityRecordModel(
+ question: json['question'] as String,
+ responses: (json['responses'] as List)
+ .map((e) => ActivityResponse.fromJson(e as Map))
+ .toList(),
+ );
+ }
+
+ Map 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 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 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;
+}
diff --git a/lib/pangea/widgets/chat/message_buttons.dart b/lib/pangea/widgets/chat/message_buttons.dart
new file mode 100644
index 000000000..f7748675f
--- /dev/null
+++ b/lib/pangea/widgets/chat/message_buttons.dart
@@ -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 {
+ 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,
+ ),
+ ),
+ ),
+ );
+ }
+}
diff --git a/lib/pangea/widgets/chat/message_toolbar.dart b/lib/pangea/widgets/chat/message_toolbar.dart
index ba508906b..434128b1e 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/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 {
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 {
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 {
);
}
+ void showPracticeActivity() {
+ toolbarContent = PracticeActivityCard(
+ pangeaMessageEvent: widget.pangeaMessageEvent,
+ controller: this,
+ );
+ }
+
void showImage() {}
void spellCheck() {}
@@ -403,9 +420,11 @@ class MessageToolbarState extends State {
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),
),
);
diff --git a/lib/pangea/widgets/practice_activity/generate_practice_activity.dart b/lib/pangea/widgets/practice_activity/generate_practice_activity.dart
new file mode 100644
index 000000000..1eae97d63
--- /dev/null
+++ b/lib/pangea/widgets/practice_activity/generate_practice_activity.dart
@@ -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),
+ );
+ }
+}
diff --git a/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart
new file mode 100644
index 000000000..c2861ffe0
--- /dev/null
+++ b/lib/pangea/widgets/practice_activity/multiple_choice_activity.dart
@@ -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,
+ ),
+ ],
+ ),
+ );
+ }
+}
diff --git a/lib/pangea/widgets/practice_activity/practice_activity_card.dart b/lib/pangea/widgets/practice_activity/practice_activity_card.dart
new file mode 100644
index 000000000..17c528b22
--- /dev/null
+++ b/lib/pangea/widgets/practice_activity/practice_activity_card.dart
@@ -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 {
+ 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 activities =
+ widget.pangeaMessageEvent.practiceActivities(langCode!);
+ if (activities.isEmpty) return;
+ final List 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,
+ );
+ }
+}
diff --git a/lib/pangea/widgets/practice_activity/practice_activity_content.dart b/lib/pangea/widgets/practice_activity/practice_activity_content.dart
new file mode 100644
index 000000000..8080c27ee
--- /dev/null
+++ b/lib/pangea/widgets/practice_activity/practice_activity_content.dart
@@ -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 {
+ 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(
+ AppConfig.primaryColor,
+ ),
+ ),
+ child: Text(L10n.of(context)!.submit),
+ ),
+ ),
+ ],
+ ),
+ ],
+ );
+ }
+}
diff --git a/lib/pangea/widgets/user_settings/p_language_dialog.dart b/lib/pangea/widgets/user_settings/p_language_dialog.dart
index 4d09b506e..51082a7e6 100644
--- a/lib/pangea/widgets/user_settings/p_language_dialog.dart
+++ b/lib/pangea/widgets/user_settings/p_language_dialog.dart
@@ -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 {
diff --git a/needed-translations.txt b/needed-translations.txt
index 6dae1ed19..1355bb368 100644
--- a/needed-translations.txt
+++ b/needed-translations.txt
@@ -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"
]
}