passing practice model instead of activity?

pull/1384/head
William Jordan-Cooley 1 year ago
parent 26752a9ba7
commit 8bffe17455

@ -22,7 +22,7 @@ void main() async {
// #Pangea
try {
await dotenv.load(fileName: ".env");
await dotenv.load(fileName: ".env.local_choreo");
} catch (e) {
Logs().e('Failed to load .env file', e);
}

@ -20,6 +20,7 @@ import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/message_mode_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/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
@ -652,15 +653,25 @@ class ChatController extends State<ChatPageWithRoom>
// There's a listen in my_analytics_controller that decides when to auto-update
// analytics based on when / how many messages the logged in user send. This
// stream sends the data for newly sent messages.
final metadata = ConstructUseMetaData(
roomId: roomId,
timeStamp: DateTime.now(),
eventId: msgEventId,
);
if (msgEventId != null) {
pangeaController.myAnalytics.setState(
AnalyticsStream(
eventId: msgEventId,
eventType: EventTypes.Message,
roomId: room.id,
originalSent: originalSent,
tokensSent: tokensSent,
choreo: choreo,
constructs: [
...(choreo!.grammarConstructUses(metadata: metadata)),
...(originalSent!.vocabUses(
choreo: choreo,
tokens: tokensSent!.tokens,
metadata: metadata,
)),
],
),
);
}

@ -67,25 +67,23 @@ class ChoicesArrayState extends State<ChoicesArray> {
: Wrap(
alignment: WrapAlignment.center,
children: widget.choices!
.mapIndexed(
(index, entry) => ChoiceItem(
theme: theme,
onLongPress:
widget.isActive ? widget.onLongPress : null,
onPressed: widget.isActive
? widget.onPressed
: (String value, int index) {
debugger(when: kDebugMode);
},
entry: MapEntry(index, entry),
interactionDisabled: interactionDisabled,
enableInteraction: enableInteractions,
disableInteraction: disableInteraction,
isSelected: widget.selectedChoiceIndex == index,
),
)
.toList() ??
[],
.mapIndexed(
(index, entry) => ChoiceItem(
theme: theme,
onLongPress: widget.isActive ? widget.onLongPress : null,
onPressed: widget.isActive
? widget.onPressed
: (String value, int index) {
debugger(when: kDebugMode);
},
entry: MapEntry(index, entry),
interactionDisabled: interactionDisabled,
enableInteraction: enableInteractions,
disableInteraction: disableInteraction,
isSelected: widget.selectedChoiceIndex == index,
),
)
.toList(),
);
}
}

@ -1,20 +1,14 @@
import 'dart:async';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.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/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/pangea_token_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:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
@ -29,7 +23,7 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
late PangeaController _pangeaController;
CachedStreamController<AnalyticsUpdateType> analyticsUpdateStream =
CachedStreamController<AnalyticsUpdateType>();
StreamSubscription<AnalyticsStream>? _messageSendSubscription;
StreamSubscription<AnalyticsStream>? _analyticsStream;
Timer? _updateTimer;
Client get _client => _pangeaController.matrixState.client;
@ -60,7 +54,7 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
void initialize() {
// Listen to a stream that provides the eventIDs
// of new messages sent by the logged in user
_messageSendSubscription ??=
_analyticsStream ??=
stateStream.listen((data) => _onNewAnalyticsData(data));
_refreshAnalyticsIfOutdated();
@ -72,8 +66,8 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
_updateTimer?.cancel();
lastUpdated = null;
lastUpdatedCompleter = Completer<DateTime?>();
_messageSendSubscription?.cancel();
_messageSendSubscription = null;
_analyticsStream?.cancel();
_analyticsStream = null;
_refreshAnalyticsIfOutdated();
clearMessagesSinceUpdate();
}
@ -109,34 +103,9 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
/// Given the data from a newly sent message, format and cache
/// the message's construct data locally and reset the update timer
void _onNewAnalyticsData(AnalyticsStream data) {
// convert that data into construct uses and add it to the cache
final metadata = ConstructUseMetaData(
roomId: data.roomId,
eventId: data.eventId,
timeStamp: DateTime.now(),
);
final List<OneConstructUse> constructs = _getDraftUses(data.roomId);
if (data.eventType == EventTypes.Message) {
constructs.addAll([
...(data.choreo!.grammarConstructUses(metadata: metadata)),
...(data.originalSent!.vocabUses(
choreo: data.choreo,
tokens: data.tokensSent!.tokens,
metadata: metadata,
)),
]);
} else if (data.eventType == PangeaEventTypes.activityRecord &&
data.practiceActivity != null) {
final activityConstructs = data.recordModel!.uses(
data.practiceActivity!,
metadata: metadata,
);
constructs.addAll(activityConstructs);
} else {
throw PangeaWarningError("Invalid event type for analytics stream");
}
constructs.addAll(data.constructs);
final String eventID = data.eventId;
final String roomID = data.roomId;
@ -342,43 +311,13 @@ class MyAnalyticsController extends BaseController<AnalyticsStream> {
class AnalyticsStream {
final String eventId;
final String eventType;
final String roomId;
/// if the event is a message, the original message sent
final PangeaRepresentation? originalSent;
/// if the event is a message, the tokens sent
final PangeaMessageTokens? tokensSent;
/// if the event is a message, the choreo record
final ChoreoRecord? choreo;
/// if the event is a practice activity, the practice activity event
final PracticeActivityEvent? practiceActivity;
/// if the event is a practice activity, the record model
final PracticeActivityRecordModel? recordModel;
final List<OneConstructUse> constructs;
AnalyticsStream({
required this.eventId,
required this.eventType,
required this.roomId,
this.originalSent,
this.tokensSent,
this.choreo,
this.practiceActivity,
this.recordModel,
}) {
assert(
(originalSent != null && tokensSent != null && choreo != null) ||
(practiceActivity != null && recordModel != null),
"Either a message or a practice activity must be provided",
);
assert(
eventType == EventTypes.Message ||
eventType == PangeaEventTypes.activityRecord,
);
}
required this.constructs,
});
}

@ -119,10 +119,28 @@ class PracticeGenerationController {
requestModel: req,
);
// if the server points to an existing event, return that event
if (res.existingActivityEventId != null) {
debugPrint(
'Existing activity event found: ${res.existingActivityEventId}',
);
final Event? existingEvent =
await event.room.getEventById(res.existingActivityEventId!);
if (existingEvent != null) {
return PracticeActivityEvent(
event: existingEvent,
timeline: event.timeline,
);
}
}
if (res.activity == null) {
debugPrint('No activity generated');
return null;
}
debugPrint('Activity generated: ${res.activity!.toJson()}');
final Future<PracticeActivityEvent?> eventFuture =
_sendAndPackageEvent(res.activity!, event);

@ -2,6 +2,7 @@ import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
@ -62,24 +63,34 @@ class PracticeActivityEvent {
/// Completion record assosiated with this activity
/// for the logged in user, null if there is none
PracticeActivityRecordEvent? get userRecord {
final List<PracticeActivityRecordEvent> records = allRecords
.where(
(recordEvent) =>
recordEvent.event.senderId ==
recordEvent.event.room.client.userID,
)
.toList();
if (records.length > 1) {
debugPrint("There should only be one record per user per activity");
debugger(when: kDebugMode);
}
return records.firstOrNull;
List<PracticeActivityRecordEvent> get allUserRecords => allRecords
.where(
(recordEvent) =>
recordEvent.event.senderId == recordEvent.event.room.client.userID,
)
.toList();
/// Get the most recent user record for this activity
PracticeActivityRecordEvent? get latestUserRecord {
final List<PracticeActivityRecordEvent> userRecords = allUserRecords;
if (userRecords.isEmpty) return null;
return userRecords.reduce(
(a, b) => a.event.originServerTs.isAfter(b.event.originServerTs) ? a : b,
);
}
DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs;
String get parentMessageId => event.relationshipEventId!;
/// Checks if there are any user records in the list for this activity,
/// and, if so, then the activity is complete
bool get isComplete => userRecord != null;
bool get isComplete => latestUserRecord != null;
ExistingActivityMetaData get activityRequestMetaData =>
ExistingActivityMetaData(
activityEventId: event.eventId,
tgtConstructs: practiceActivity.tgtConstructs,
activityType: practiceActivity.activityType,
);
}

@ -34,13 +34,11 @@ class PangeaToken {
int endTokenIndex = -1,
]) {
if (endTokenIndex == -1) {
endTokenIndex = tokens.length - 1;
endTokenIndex = tokens.length;
}
final List<PangeaToken> subset =
tokens.whereIndexed((int index, PangeaToken token) {
return index >= startTokenIndex && index <= endTokenIndex;
}).toList();
tokens.sublist(startTokenIndex, endTokenIndex);
if (subset.isEmpty) {
debugger(when: kDebugMode);
@ -51,10 +49,11 @@ class PangeaToken {
return subset.first.text.content;
}
String reconstruction = subset.first.text.content;
for (int i = 1; i < subset.length - 1; i++) {
String reconstruction = "";
for (int i = 0; i < subset.length; i++) {
int whitespace = subset[i].text.offset -
(subset[i - 1].text.offset + subset[i - 1].text.length);
(i > 0 ? (subset[i - 1].text.offset + subset[i - 1].text.length) : 0);
if (whitespace < 0) {
debugger(when: kDebugMode);
whitespace = 0;

@ -1,11 +1,12 @@
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
class ConstructWithXP {
final ConstructIdentifier id;
int xp;
final DateTime? lastUsed;
DateTime? lastUsed;
ConstructWithXP({
required this.id,
@ -94,13 +95,52 @@ class TokenWithXP {
}
}
class ExistingActivityMetaData {
final String activityEventId;
final List<ConstructIdentifier> tgtConstructs;
final ActivityTypeEnum activityType;
ExistingActivityMetaData({
required this.activityEventId,
required this.tgtConstructs,
required this.activityType,
});
factory ExistingActivityMetaData.fromJson(Map<String, dynamic> json) {
return ExistingActivityMetaData(
activityEventId: json['activity_event_id'] as String,
tgtConstructs: (json['tgt_constructs'] as List)
.map((e) => ConstructIdentifier.fromJson(e as Map<String, dynamic>))
.toList(),
activityType: ActivityTypeEnum.values.firstWhere(
(element) =>
element.string == json['activity_type'] as String ||
element.string.split('.').last == json['activity_type'] as String,
),
);
}
Map<String, dynamic> toJson() {
return {
'activity_event_id': activityEventId,
'tgt_constructs': tgtConstructs.map((e) => e.toJson()).toList(),
'activity_type': activityType.string,
};
}
}
class MessageActivityRequest {
final String userL1;
final String userL2;
final String messageText;
/// tokens with their associated constructs and xp
final List<TokenWithXP> tokensWithXP;
/// make the server aware of existing activities for potential reuse
final List<ExistingActivityMetaData> existingActivities;
final String messageId;
MessageActivityRequest({
@ -109,6 +149,7 @@ class MessageActivityRequest {
required this.messageText,
required this.tokensWithXP,
required this.messageId,
required this.existingActivities,
});
factory MessageActivityRequest.fromJson(Map<String, dynamic> json) {
@ -120,6 +161,11 @@ class MessageActivityRequest {
.map((e) => TokenWithXP.fromJson(e as Map<String, dynamic>))
.toList(),
messageId: json['message_id'] as String,
existingActivities: (json['existing_activities'] as List)
.map(
(e) => ExistingActivityMetaData.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
}
@ -130,6 +176,7 @@ class MessageActivityRequest {
'message_text': messageText,
'tokens_with_xp': tokensWithXP.map((e) => e.toJson()).toList(),
'message_id': messageId,
'existing_activities': existingActivities.map((e) => e.toJson()).toList(),
};
}
@ -152,10 +199,12 @@ class MessageActivityRequest {
class MessageActivityResponse {
final PracticeActivityModel? activity;
final bool finished;
final String? existingActivityEventId;
MessageActivityResponse({
required this.activity,
required this.finished,
required this.existingActivityEventId,
});
factory MessageActivityResponse.fromJson(Map<String, dynamic> json) {
@ -166,6 +215,7 @@ class MessageActivityResponse {
)
: null,
finished: json['finished'] as bool,
existingActivityEventId: json['existing_activity_event_id'] as String?,
);
}
@ -173,6 +223,7 @@ class MessageActivityResponse {
return {
'activity': activity?.toJson(),
'finished': finished,
'existing_activity_event_id': existingActivityEventId,
};
}
}

@ -5,12 +5,10 @@
import 'dart:developer';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
class PracticeActivityRecordModel {
final String? question;
@ -57,12 +55,6 @@ class PracticeActivityRecordModel {
return responses[responses.length - 1];
}
ConstructUseTypeEnum get useType => latestResponse?.score != null
? (latestResponse!.score > 0
? ConstructUseTypeEnum.corPA
: ConstructUseTypeEnum.incPA)
: ConstructUseTypeEnum.unk;
bool hasTextResponse(String text) {
return responses.any((element) => element.text == text);
}
@ -91,50 +83,50 @@ class PracticeActivityRecordModel {
/// Returns a list of [OneConstructUse] objects representing the uses of the practice activity.
///
/// The [practiceActivity] parameter is the parent event, representing the activity itself.
/// The [event] parameter is the record event, if available.
/// The [metadata] parameter is the metadata for the construct use, used if the record event isn't available.
///
/// If [event] and [metadata] are both null, an empty list is returned.
///
/// The method iterates over the [tgtConstructs] of the [practiceActivity] and creates a [OneConstructUse] object for each construct.
/// The method iterates over the [tgtConstructs] of the [practiceActivity] and creates a [OneConstructUse] object for each construct and useType.
List<OneConstructUse> uses(
PracticeActivityEvent practiceActivity, {
Event? event,
ConstructUseMetaData? metadata,
}) {
PracticeActivityModel practiceActivity,
ConstructUseMetaData metadata,
) {
try {
if (event == null && metadata == null) {
debugger(when: kDebugMode);
return [];
}
final List<OneConstructUse> uses = [];
final List<ConstructIdentifier> constructIds =
practiceActivity.practiceActivity.tgtConstructs;
for (final construct in constructIds) {
uses.add(
OneConstructUse(
lemma: construct.lemma,
constructType: construct.type,
useType: useType,
//TODO - find form of construct within the message
//this is related to the feature of highlighting the target construct in the message
form: construct.lemma,
metadata: ConstructUseMetaData(
roomId: event?.roomId ?? metadata!.roomId,
eventId: practiceActivity.parentMessageId,
timeStamp: event?.originServerTs ?? metadata!.timeStamp,
final uniqueResponses = responses.toSet();
final List<ConstructUseTypeEnum> useTypes =
uniqueResponses.map((response) => response.useType).toList();
for (final construct in practiceActivity.tgtConstructs) {
for (final useType in useTypes) {
uses.add(
OneConstructUse(
lemma: construct.lemma,
constructType: construct.type,
useType: useType,
//TODO - find form of construct within the message
//this is related to the feature of highlighting the target construct in the message
form: construct.lemma,
metadata: metadata,
),
),
);
);
}
}
return uses;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s, data: event?.toJson());
rethrow;
ErrorHandler.logError(
e: e,
s: s,
data: {
'recordModel': toJson(),
'practiceActivity': practiceActivity,
'metadata': metadata,
},
);
return [];
}
}
@ -172,6 +164,9 @@ class ActivityRecordResponse {
required this.timestamp,
});
ConstructUseTypeEnum get useType =>
score > 0 ? ConstructUseTypeEnum.corPA : ConstructUseTypeEnum.incPA;
factory ActivityRecordResponse.fromJson(Map<String, dynamic> json) {
return ActivityRecordResponse(
text: json['text'] as String?,

@ -58,7 +58,11 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
/// Whether the user has completed the activities needed to unlock the toolbar
/// within this overlay 'session'. if they click out and come back in then
/// we can give them some more activities to complete
bool finishedActivitiesThisSession = false;
int completedThisSession = 0;
bool get finishedActivitiesThisSession => completedThisSession >= needed;
late int activitiesLeftToComplete = needed;
@override
void initState() {
@ -68,17 +72,29 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
duration: FluffyThemes.animationDuration,
);
activitiesLeftToComplete =
needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted;
setInitialToolbarMode();
}
int get activitiesLeftToComplete =>
needed - widget._pangeaMessageEvent.numberOfActivitiesCompleted;
bool get isPracticeComplete => activitiesLeftToComplete <= 0;
/// When an activity is completed, we need to update the state
/// and check if the toolbar should be unlocked
void onActivityFinish() {
if (!mounted) return;
completedThisSession += 1;
activitiesLeftToComplete -= 1;
clearSelection();
setState(() {});
}
/// In some cases, we need to exit the practice flow and let the user
/// interact with the toolbar without completing activities
void exitPracticeFlow() {
debugPrint('Exiting practice flow');
clearSelection();
needed = 0;
setInitialToolbarMode();
setState(() {});
@ -129,6 +145,10 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
void onClickOverlayMessageToken(
PangeaToken token,
) {
if (toolbarMode == MessageMode.practiceActivity) {
return;
}
// if there's no selected span, then select the token
if (_selectedSpan == null) {
_selectedSpan = token.text;
@ -150,7 +170,7 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
setState(() {});
}
void onNewActivity(PracticeActivityModel activity) {
void setSelectedSpan(PracticeActivityModel activity) {
final RelevantSpanDisplayDetails? span =
activity.multipleChoice?.spanDisplayDetails;

@ -250,7 +250,6 @@ class ToolbarButtonsState extends State<ToolbarButtons> {
.toList();
static const double iconWidth = 36.0;
double get progressWidth => widget.width / overlayController.needed;
MessageOverlayController get overlayController =>
widget.messageToolbarController.widget.overLayController;
@ -263,6 +262,8 @@ class ToolbarButtonsState extends State<ToolbarButtons> {
@override
Widget build(BuildContext context) {
final double barWidth = widget.width - iconWidth;
if (widget
.messageToolbarController.widget.pangeaMessageEvent.isAudioMessage) {
return const SizedBox();
@ -286,11 +287,13 @@ class ToolbarButtonsState extends State<ToolbarButtons> {
AnimatedContainer(
duration: FluffyThemes.animationDuration,
height: 12,
width: min(
widget.width,
progressWidth *
pangeaMessageEvent.numberOfActivitiesCompleted,
),
width: overlayController.isPracticeComplete
? barWidth
: min(
barWidth,
(barWidth / 3) *
pangeaMessageEvent.numberOfActivitiesCompleted,
),
color: AppConfig.success,
margin: const EdgeInsets.symmetric(horizontal: iconWidth / 2),
),

@ -29,7 +29,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
widget.practiceCardController.currentCompletionRecord;
bool get isSubmitted =>
widget.currentActivity?.userRecord?.record.latestResponse != null;
widget.currentActivity?.latestUserRecord?.record.latestResponse != null;
@override
void initState() {
@ -52,7 +52,7 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
/// Otherwise, it sets the current model to the user record's record and
/// determines the selected choice index.
void setCompletionRecord() {
if (widget.currentActivity?.userRecord?.record == null) {
if (widget.currentActivity?.latestUserRecord?.record == null) {
widget.practiceCardController.setCompletionRecord(
PracticeActivityRecordModel(
question:
@ -61,8 +61,8 @@ class MultipleChoiceActivityState extends State<MultipleChoiceActivity> {
);
selectedChoiceIndex = null;
} else {
widget.practiceCardController
.setCompletionRecord(widget.currentActivity!.userRecord!.record);
widget.practiceCardController.setCompletionRecord(
widget.currentActivity!.latestUserRecord!.record);
selectedChoiceIndex = widget
.currentActivity?.practiceActivity.multipleChoice!
.choiceIndex(currentRecordModel!.latestResponse!.text!);

@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
@ -9,8 +8,8 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar
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/analytics/construct_list_model.dart';
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.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/utils/bot_style.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
@ -21,7 +20,6 @@ import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
/// The wrapper for practice activity content.
/// Handles the activities associated with a message,
@ -46,18 +44,14 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
PracticeActivityRecordModel? currentCompletionRecord;
bool fetchingActivity = false;
List<TokenWithXP> targetTokens = [];
TargetTokensController targetTokensController = TargetTokensController();
List<PracticeActivityEvent> get practiceActivities =>
widget.pangeaMessageEvent.practiceActivities;
int get practiceEventIndex => practiceActivities.indexWhere(
(activity) => activity.event.eventId == currentActivity?.event.eventId,
);
/// TODO - @ggurdin - how can we start our processes (saving results and getting an activity)
/// immediately after a correct choice but wait to display until x milliseconds after the choice is made AND
/// we've received the new activity?
// Used to show an animation when the user completes an activity
// while simultaneously fetching a new activity and not showing the loading spinner
// until the appropriate time has passed to 'savor the joy'
Duration appropriateTimeForJoy = const Duration(milliseconds: 500);
bool savoringTheJoy = false;
Timer? joyTimer;
@ -68,150 +62,100 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
initialize();
}
void updateFetchingActivity(bool value) {
@override
void dispose() {
joyTimer?.cancel();
super.dispose();
}
void _updateFetchingActivity(bool value) {
if (fetchingActivity == value) return;
setState(() => fetchingActivity = value);
}
/// Get an activity to display.
/// Show an uncompleted activity if there is one.
/// Set target tokens.
/// Get an existing activity if there is one.
/// If not, get a new activity from the server.
Future<void> initialize() async {
targetTokens = await getTargetTokens();
currentActivity = _fetchExistingActivity() ?? await _fetchNewActivity();
currentActivity =
_fetchExistingIncompleteActivity() ?? await _fetchNewActivity();
currentActivity == null
? widget.overlayController.exitPracticeFlow()
: widget.overlayController
.onNewActivity(currentActivity!.practiceActivity);
.setSelectedSpan(currentActivity!.practiceActivity);
}
// TODO - do more of a check for whether we have an appropropriate activity
// if the user did the activity before but awhile ago and we don't have any
// more target tokens, maybe we should give them the same activity again
PracticeActivityEvent? _fetchExistingActivity() {
PracticeActivityEvent? _fetchExistingIncompleteActivity() {
if (practiceActivities.isEmpty) {
return null;
}
final List<PracticeActivityEvent> incompleteActivities =
practiceActivities.where((element) => !element.isComplete).toList();
final PracticeActivityEvent? existingActivity =
incompleteActivities.isNotEmpty ? incompleteActivities.first : null;
return existingActivity != null &&
existingActivity.practiceActivity !=
currentActivity?.practiceActivity
? existingActivity
: null;
// TODO - maybe check the user's xp for the tgtConstructs and decide if its relevant for them
// however, maybe we'd like to go ahead and give them the activity to get some data on our xp?
return incompleteActivities.firstOrNull;
}
Future<PracticeActivityEvent?> _fetchNewActivity() async {
updateFetchingActivity(true);
if (targetTokens.isEmpty ||
!pangeaController.languageController.languagesSet) {
debugger(when: kDebugMode);
updateFetchingActivity(false);
return null;
}
final ourNewActivity =
await pangeaController.practiceGenerationController.getPracticeActivity(
MessageActivityRequest(
userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode,
messageText: representation!.text,
tokensWithXP: targetTokens,
messageId: widget.pangeaMessageEvent.eventId,
),
widget.pangeaMessageEvent,
);
try {
debugPrint('Fetching new activity');
/// Removes the target tokens of the new activity from the target tokens list.
/// This avoids getting activities for the same token again, at least
/// until the user exists the toolbar and re-enters it. By then, the
/// analytics stream will have updated and the user will be able to get
/// activity data for previously targeted tokens. This should then exclude
/// the tokens that were targeted in previous activities based on xp and lastUsed.
if (ourNewActivity?.practiceActivity.relevantSpanDisplayDetails != null) {
targetTokens.removeWhere((token) {
final RelevantSpanDisplayDetails span =
ourNewActivity!.practiceActivity.relevantSpanDisplayDetails!;
return token.token.text.offset >= span.offset &&
token.token.text.offset + token.token.text.length <=
span.offset + span.length;
});
}
_updateFetchingActivity(true);
updateFetchingActivity(false);
// target tokens can be empty if activities have been completed for each
// it's set on initialization and then removed when each activity is completed
if (!pangeaController.languageController.languagesSet) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
return ourNewActivity;
}
if (!mounted) {
debugger(when: kDebugMode);
_updateFetchingActivity(false);
return null;
}
/// From the tokens in the message, do a preliminary filtering of which to target
/// Then get the construct uses for those tokens
Future<List<TokenWithXP>> getTargetTokens() async {
if (!mounted) {
ErrorHandler.logError(
m: 'getTargetTokens called when not mounted',
s: StackTrace.current,
final PracticeActivityEvent? ourNewActivity = await pangeaController
.practiceGenerationController
.getPracticeActivity(
MessageActivityRequest(
userL1: pangeaController.languageController.userL1!.langCode,
userL2: pangeaController.languageController.userL2!.langCode,
messageText: representation!.text,
tokensWithXP: await targetTokensController.targetTokens(
context,
widget.pangeaMessageEvent,
),
messageId: widget.pangeaMessageEvent.eventId,
existingActivities: practiceActivities
.map((activity) => activity.activityRequestMetaData)
.toList(),
),
widget.pangeaMessageEvent,
);
return [];
}
// we're just going to set this once per session
// we remove the target tokens when we get a new activity
if (targetTokens.isNotEmpty) return targetTokens;
_updateFetchingActivity(false);
if (representation == null) {
debugger(when: kDebugMode);
return [];
}
final tokens = await representation?.tokensGlobal(context);
if (tokens == null || tokens.isEmpty) {
debugger(when: kDebugMode);
return [];
}
var constructUses =
MatrixState.pangeaController.analytics.analyticsStream.value;
if (constructUses == null || constructUses.isEmpty) {
constructUses = [];
//@gurdin - this is happening for me with a brand-new user. however, in this case, constructUses should be empty list
return ourNewActivity;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
m: 'Failed to get new activity',
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
},
);
return null;
}
final ConstructListModel constructList = ConstructListModel(
uses: constructUses,
type: null,
);
final List<TokenWithXP> tokenCounts = [];
// TODO - add morph constructs to this list as well
for (int i = 0; i < tokens.length; i++) {
//don't bother with tokens that we don't save to vocab
if (!tokens[i].lemma.saveVocab) {
continue;
}
tokenCounts.add(tokens[i].emptyTokenWithXP);
for (final construct in tokenCounts.last.constructs) {
final constructUseModel = constructList.getConstructUses(
construct.id.lemma,
construct.id.type,
);
if (constructUseModel != null) {
construct.xp = constructUseModel.points;
}
}
}
tokenCounts.sort((a, b) => a.xp.compareTo(b.xp));
return tokenCounts;
}
void setCompletionRecord(PracticeActivityRecordModel? recordModel) {
@ -219,7 +163,7 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
}
/// future that simply waits for the appropriate time to savor the joy
Future<void> savorTheJoy() async {
Future<void> _savorTheJoy() async {
joyTimer?.cancel();
if (savoringTheJoy) return;
savoringTheJoy = true;
@ -229,10 +173,10 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
});
}
/// Sends the current record model and activity to the server.
/// If either the currentRecordModel or currentActivity is null, the method returns early.
/// If the currentActivity is the last activity, the method sets the appropriate flag to true.
/// If the currentActivity is not the last activity, the method fetches a new activity.
/// Called when the user finishes an activity.
/// Saves the completion record and sends it to the server.
/// Fetches a new activity if there are any left to complete.
/// Exits the practice flow if there are no more activities.
void onActivityFinish() async {
try {
if (currentCompletionRecord == null || currentActivity == null) {
@ -241,45 +185,63 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
}
// start joy timer
savorTheJoy();
_savorTheJoy();
// if this is the last activity, set the flag to true
// so we can give them some kudos
if (widget.overlayController.activitiesLeftToComplete == 1) {
widget.overlayController.finishedActivitiesThisSession = true;
}
final uses = currentCompletionRecord!.uses(
currentActivity!.practiceActivity,
ConstructUseMetaData(
roomId: widget.pangeaMessageEvent.room.id,
timeStamp: DateTime.now(),
),
);
final Event? event = await MatrixState
.pangeaController.activityRecordController
.send(currentCompletionRecord!, currentActivity!);
// update the target tokens with the new construct uses
targetTokensController.updateTokensWithConstructs(
uses,
context,
widget.pangeaMessageEvent,
);
MatrixState.pangeaController.myAnalytics.setState(
AnalyticsStream(
// note - this maybe should be the activity event id
eventId: widget.pangeaMessageEvent.eventId,
eventType: PangeaEventTypes.activityRecord,
roomId: event!.room.id,
practiceActivity: currentActivity!,
recordModel: currentCompletionRecord!,
roomId: widget.pangeaMessageEvent.room.id,
constructs: uses,
),
);
if (!widget.overlayController.finishedActivitiesThisSession) {
currentActivity = await _fetchNewActivity();
// save the record without awaiting to avoid blocking the UI
// send a copy of the activity record to make sure its not overwritten by
// the new activity
MatrixState.pangeaController.activityRecordController
.send(currentCompletionRecord!, currentActivity!)
.catchError(
(e, s) => ErrorHandler.logError(
e: e,
s: s,
m: 'Failed to save record',
data: {
'record': currentCompletionRecord?.toJson(),
'activity': currentActivity?.practiceActivity.toJson(),
},
),
);
widget.overlayController.onActivityFinish();
currentActivity == null
? widget.overlayController.exitPracticeFlow()
: widget.overlayController
.onNewActivity(currentActivity!.practiceActivity);
} else {
updateFetchingActivity(false);
widget.overlayController.setState(() {});
}
currentActivity = await _fetchNewActivity();
currentActivity == null
? widget.overlayController.exitPracticeFlow()
: widget.overlayController
.setSelectedSpan(currentActivity!.practiceActivity);
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
m: 'Failed to send record for activity',
m: 'Failed to get new activity',
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
@ -340,6 +302,9 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
@override
Widget build(BuildContext context) {
debugPrint(
'Building practice activity card with ${widget.overlayController.activitiesLeftToComplete} activities left to complete',
);
if (userMessage != null) {
return Center(
child: Container(
@ -385,3 +350,92 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
);
}
}
/// Seperated out the target tokens from the practice activity card
/// in order to control the state of the target tokens
class TargetTokensController {
List<TokenWithXP>? _targetTokens;
TargetTokensController();
/// From the tokens in the message, do a preliminary filtering of which to target
/// Then get the construct uses for those tokens
Future<List<TokenWithXP>> targetTokens(
BuildContext context,
PangeaMessageEvent pangeaMessageEvent,
) async {
if (_targetTokens != null) {
return _targetTokens!;
}
_targetTokens = await _initialize(context, pangeaMessageEvent);
await updateTokensWithConstructs(
MatrixState.pangeaController.analytics.analyticsStream.value ?? [],
context,
pangeaMessageEvent,
);
return _targetTokens!;
}
Future<List<TokenWithXP>> _initialize(
BuildContext context,
PangeaMessageEvent pangeaMessageEvent,
) async {
if (!context.mounted) {
ErrorHandler.logError(
m: 'getTargetTokens called when not mounted',
s: StackTrace.current,
);
return _targetTokens = [];
}
final tokens = await pangeaMessageEvent
.representationByLanguage(pangeaMessageEvent.messageDisplayLangCode)
?.tokensGlobal(context);
if (tokens == null || tokens.isEmpty) {
debugger(when: kDebugMode);
return _targetTokens = [];
}
_targetTokens = [];
for (int i = 0; i < tokens.length; i++) {
//don't bother with tokens that we don't save to vocab
if (!tokens[i].lemma.saveVocab) {
continue;
}
_targetTokens!.add(tokens[i].emptyTokenWithXP);
}
return _targetTokens!;
}
Future<void> updateTokensWithConstructs(
List<OneConstructUse> constructUses,
context,
pangeaMessageEvent,
) async {
final ConstructListModel constructList = ConstructListModel(
uses: constructUses,
type: null,
);
_targetTokens ??= await _initialize(context, pangeaMessageEvent);
for (final token in _targetTokens!) {
for (final construct in token.constructs) {
final constructUseModel = constructList.getConstructUses(
construct.id.lemma,
construct.id.type,
);
if (constructUseModel != null) {
construct.xp = constructUseModel.points;
construct.lastUsed = constructUseModel.lastUsed;
}
}
}
}
}

Loading…
Cancel
Save