You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fluffychat/lib/pangea/widgets/practice_activity/practice_activity_card.dart

388 lines
13 KiB
Dart

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';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.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/analytics/construct_list_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';
import 'package:fluffychat/pangea/widgets/animations/gain_points.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/practice_activity/multiple_choice_activity.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';
import 'package:matrix/matrix.dart';
/// The wrapper for practice activity content.
/// Handles the activities associated with a message,
/// their navigation, and the management of completion records
class PracticeActivityCard extends StatefulWidget {
final PangeaMessageEvent pangeaMessageEvent;
final MessageOverlayController overlayController;
const PracticeActivityCard({
super.key,
required this.pangeaMessageEvent,
required this.overlayController,
});
@override
MessagePracticeActivityCardState createState() =>
MessagePracticeActivityCardState();
}
class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
PracticeActivityEvent? currentActivity;
PracticeActivityRecordModel? currentCompletionRecord;
bool fetchingActivity = false;
List<TokenWithXP> targetTokens = [];
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?
Duration appropriateTimeForJoy = const Duration(milliseconds: 500);
bool savoringTheJoy = false;
Timer? joyTimer;
@override
void initState() {
super.initState();
initialize();
}
void updateFetchingActivity(bool value) {
if (fetchingActivity == value) return;
setState(() => fetchingActivity = value);
}
/// Get an activity to display.
/// Show an uncompleted 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 == null
? widget.overlayController.exitPracticeFlow()
: widget.overlayController
.onNewActivity(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() {
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;
}
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,
);
/// 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(false);
return ourNewActivity;
}
/// 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,
);
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;
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
debugger(when: kDebugMode);
}
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) {
currentCompletionRecord = recordModel;
}
/// future that simply waits for the appropriate time to savor the joy
Future<void> savorTheJoy() async {
joyTimer?.cancel();
if (savoringTheJoy) return;
savoringTheJoy = true;
joyTimer = Timer(appropriateTimeForJoy, () {
savoringTheJoy = false;
joyTimer?.cancel();
});
}
/// 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.
void onActivityFinish() async {
try {
if (currentCompletionRecord == null || currentActivity == null) {
debugger(when: kDebugMode);
return;
}
// start joy timer
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 Event? event = await MatrixState
.pangeaController.activityRecordController
.send(currentCompletionRecord!, currentActivity!);
MatrixState.pangeaController.myAnalytics.setState(
AnalyticsStream(
eventId: widget.pangeaMessageEvent.eventId,
eventType: PangeaEventTypes.activityRecord,
roomId: event!.room.id,
practiceActivity: currentActivity!,
recordModel: currentCompletionRecord!,
),
);
if (!widget.overlayController.finishedActivitiesThisSession) {
currentActivity = await _fetchNewActivity();
currentActivity == null
? widget.overlayController.exitPracticeFlow()
: widget.overlayController
.onNewActivity(currentActivity!.practiceActivity);
} else {
updateFetchingActivity(false);
widget.overlayController.setState(() {});
}
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(
e: e,
s: s,
m: 'Failed to send record for activity',
data: {
'activity': currentActivity,
'record': currentCompletionRecord,
},
);
widget.overlayController.exitPracticeFlow();
}
}
RepresentationEvent? get representation =>
widget.pangeaMessageEvent.originalSent;
String get messsageText => representation!.text;
PangeaController get pangeaController => MatrixState.pangeaController;
/// The widget that displays the current activity.
/// If there is no current activity, the widget returns a sizedbox with a height of 80.
/// If the activity type is multiple choice, the widget returns a MultipleChoiceActivity.
/// If the activity type is unknown, the widget logs an error and returns a text widget with an error message.
Widget get activityWidget {
if (currentActivity == null) {
// return sizedbox with height of 80
return const SizedBox(height: 80);
}
switch (currentActivity!.practiceActivity.activityType) {
case ActivityTypeEnum.multipleChoice:
return MultipleChoiceActivity(
practiceCardController: this,
currentActivity: currentActivity,
);
default:
ErrorHandler.logError(
e: Exception('Unknown activity type'),
m: 'Unknown activity type',
data: {
'activityType': currentActivity!.practiceActivity.activityType,
},
);
return Text(
L10n.of(context)!.oopsSomethingWentWrong,
style: BotStyle.text(context),
);
}
}
String? get userMessage {
// if the user has finished all the activities to unlock the toolbar in this session
if (widget.overlayController.finishedActivitiesThisSession) {
return "Boom! Tools unlocked!";
// if we have no activities to show
} else if (!fetchingActivity && currentActivity == null) {
return L10n.of(context)!.noActivitiesFound;
}
return null;
}
@override
Widget build(BuildContext context) {
if (userMessage != null) {
return Center(
child: Container(
constraints: const BoxConstraints(
minHeight: 80,
),
child: Text(
userMessage!,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
);
}
return Stack(
alignment: Alignment.center,
children: [
// Main content
const Positioned(
child: PointsGainedAnimation(),
),
Column(
children: [
activityWidget,
// navigationButtons,
],
),
// Conditionally show the darkening and progress indicator based on the loading state
if (!savoringTheJoy && fetchingActivity) ...[
// Semi-transparent overlay
Container(
color: Colors.black.withOpacity(0.5), // Darkening effect
),
// Circular progress indicator in the center
const Center(
child: CircularProgressIndicator(),
),
],
],
);
}
}