350 lines
10 KiB
Dart
350 lines
10 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import 'package:fluffychat/l10n/l10n.dart';
|
|
import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart';
|
|
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
|
import 'package:fluffychat/pangea/analytics_misc/example_message_util.dart';
|
|
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_analytics_controller.dart';
|
|
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
|
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_data_service.dart';
|
|
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_controller.dart';
|
|
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_ui_controller.dart';
|
|
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_view.dart';
|
|
import 'package:fluffychat/pangea/common/utils/async_state.dart';
|
|
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
|
import 'package:fluffychat/pangea/common/widgets/feedback_dialog.dart';
|
|
import 'package:fluffychat/pangea/languages/language_model.dart';
|
|
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
|
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
|
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
|
import 'package:fluffychat/widgets/matrix.dart';
|
|
|
|
class SelectedMorphChoice {
|
|
final MorphFeaturesEnum feature;
|
|
final String tag;
|
|
|
|
const SelectedMorphChoice({required this.feature, required this.tag});
|
|
}
|
|
|
|
class AnalyticsPracticeNotifier extends ChangeNotifier {
|
|
String? _lastSelectedChoice;
|
|
bool showHint = false;
|
|
final Set<String> _clickedChoices = {};
|
|
|
|
int correctAnswersSelected(MultipleChoicePracticeActivityModel? activity) {
|
|
if (activity == null) return 0;
|
|
final allAnswers = activity.multipleChoiceContent.answers;
|
|
return _clickedChoices.where((c) => allAnswers.contains(c)).length;
|
|
}
|
|
|
|
bool enableHintPress(
|
|
MultipleChoicePracticeActivityModel? activity,
|
|
int hintsUsed,
|
|
) {
|
|
if (showHint) return false;
|
|
return switch (activity) {
|
|
VocabAudioPracticeActivityModel() => true,
|
|
_ => hintsUsed < AnalyticsPracticeConstants.maxHints,
|
|
};
|
|
}
|
|
|
|
SelectedMorphChoice? selectedMorphChoice(
|
|
MultipleChoicePracticeActivityModel? activity,
|
|
) {
|
|
if (activity is! MorphPracticeActivityModel) return null;
|
|
if (_lastSelectedChoice == null) return null;
|
|
return SelectedMorphChoice(
|
|
feature: activity.morphFeature,
|
|
tag: _lastSelectedChoice!,
|
|
);
|
|
}
|
|
|
|
bool activityComplete(MultipleChoicePracticeActivityModel? activity) {
|
|
if (activity == null) return false;
|
|
final allAnswers = activity.multipleChoiceContent.answers;
|
|
return allAnswers.every((answer) => _clickedChoices.contains(answer));
|
|
}
|
|
|
|
bool hasSelectedChoice(String choice) => _clickedChoices.contains(choice);
|
|
|
|
void clearActivityState() {
|
|
_lastSelectedChoice = null;
|
|
_clickedChoices.clear();
|
|
showHint = false;
|
|
}
|
|
|
|
void toggleShowHint() {
|
|
showHint = !showHint;
|
|
notifyListeners();
|
|
}
|
|
|
|
void selectChoice(String choice) {
|
|
_clickedChoices.add(choice);
|
|
_lastSelectedChoice = choice;
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
typedef ActivityNotifier =
|
|
ValueNotifier<AsyncState<MultipleChoicePracticeActivityModel>>;
|
|
|
|
class AnalyticsPractice extends StatefulWidget {
|
|
static bool bypassExitConfirmation = true;
|
|
|
|
final ConstructTypeEnum type;
|
|
const AnalyticsPractice({super.key, required this.type});
|
|
|
|
@override
|
|
AnalyticsPracticeState createState() => AnalyticsPracticeState();
|
|
}
|
|
|
|
class AnalyticsPracticeState extends State<AnalyticsPractice>
|
|
with AnalyticsUpdater {
|
|
final PracticeSessionController _sessionController =
|
|
PracticeSessionController();
|
|
|
|
final AnalyticsPracticeDataService _dataService =
|
|
AnalyticsPracticeDataService();
|
|
|
|
late final AnalyticsPracticeAnalyticsController _analyticsController;
|
|
StreamSubscription<void>? _languageStreamSubscription;
|
|
|
|
final ActivityNotifier activityState = ActivityNotifier(
|
|
const AsyncState.idle(),
|
|
);
|
|
final AnalyticsPracticeNotifier notifier = AnalyticsPracticeNotifier();
|
|
final ValueNotifier<double> progress = ValueNotifier<double>(0);
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
_analyticsController = AnalyticsPracticeAnalyticsController(
|
|
Matrix.of(context).analyticsDataService,
|
|
);
|
|
|
|
_addLanguageSubscription();
|
|
startSession();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_languageStreamSubscription?.cancel();
|
|
notifier.dispose();
|
|
activityState.dispose();
|
|
progress.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
PracticeSessionController get session => _sessionController;
|
|
AnalyticsPracticeDataService get data => _dataService;
|
|
|
|
LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2;
|
|
|
|
MultipleChoicePracticeActivityModel? get activity {
|
|
final state = activityState.value;
|
|
if (state is! AsyncLoaded<MultipleChoicePracticeActivityModel>) {
|
|
return null;
|
|
}
|
|
|
|
return state.value;
|
|
}
|
|
|
|
Future<double> get levelProgress =>
|
|
_analyticsController.levelProgress(_l2!.langCodeShort);
|
|
|
|
Future<List<InlineSpan>?> get exampleMessage async {
|
|
final activity = this.activity;
|
|
if (activity == null) return null;
|
|
|
|
return switch (activity) {
|
|
VocabAudioPracticeActivityModel() =>
|
|
activity.exampleMessage.exampleMessage,
|
|
MorphCategoryPracticeActivityModel() =>
|
|
activity.exampleMessageInfo.exampleMessage,
|
|
_ => ExampleMessageUtil.getExampleMessage(
|
|
await _analyticsController.getTargetTokenConstruct(
|
|
activity.practiceTarget,
|
|
_l2!.langCodeShort,
|
|
),
|
|
),
|
|
};
|
|
}
|
|
|
|
bool _autoLaunchNextActivity(MultipleChoicePracticeActivityModel activity) =>
|
|
activity is! VocabAudioPracticeActivityModel;
|
|
|
|
void _clearState() {
|
|
_dataService.clear();
|
|
_sessionController.clear();
|
|
AnalyticsPractice.bypassExitConfirmation = true;
|
|
_clearActivityState();
|
|
}
|
|
|
|
void _clearActivityState({bool loadingActivity = false}) {
|
|
notifier.clearActivityState();
|
|
activityState.value = loadingActivity
|
|
? AsyncState.loading()
|
|
: AsyncState.idle();
|
|
}
|
|
|
|
void _addLanguageSubscription() {
|
|
_languageStreamSubscription ??= MatrixState
|
|
.pangeaController
|
|
.userController
|
|
.languageStream
|
|
.stream
|
|
.listen((_) => _onLanguageUpdate());
|
|
}
|
|
|
|
Future<void> _onLanguageUpdate() async {
|
|
try {
|
|
_clearState();
|
|
await _analyticsController.waitForUpdate();
|
|
await startSession();
|
|
} catch (e) {
|
|
if (mounted) {
|
|
activityState.value = AsyncState.error(e);
|
|
}
|
|
}
|
|
}
|
|
|
|
void onHintPressed({bool increment = true}) {
|
|
if (increment) _sessionController.updateHintsPressed();
|
|
notifier.toggleShowHint();
|
|
}
|
|
|
|
void _playActivityAudio(MultipleChoicePracticeActivityModel activity) =>
|
|
AnalyticsPracticeUiController.playTargetAudio(
|
|
activity,
|
|
widget.type,
|
|
_l2!.langCodeShort,
|
|
);
|
|
|
|
Future<void> startSession() async {
|
|
_clearState();
|
|
await _analyticsController.waitForAnalytics();
|
|
await _sessionController.startSession(widget.type);
|
|
if (mounted) setState(() {});
|
|
|
|
if (_sessionController.sessionError != null) {
|
|
AnalyticsPractice.bypassExitConfirmation = true;
|
|
} else {
|
|
progress.value = _sessionController.progress;
|
|
await _continueSession();
|
|
}
|
|
}
|
|
|
|
Future<void> _completeSession() async {
|
|
_sessionController.completeSession();
|
|
setState(() {});
|
|
|
|
final bonus = _sessionController.bonusUses;
|
|
await _analyticsController.addSessionAnalytics(bonus, _l2!.langCodeShort);
|
|
AnalyticsPractice.bypassExitConfirmation = true;
|
|
}
|
|
|
|
Future<void> _continueSession() async {
|
|
if (activityState.value
|
|
is AsyncLoading<MultipleChoicePracticeActivityModel>) {
|
|
return;
|
|
}
|
|
|
|
_clearActivityState(loadingActivity: true);
|
|
|
|
try {
|
|
final resp = await _sessionController.getNextActivity(
|
|
skipActivity,
|
|
_dataService.prefetchActivityInfo,
|
|
);
|
|
|
|
if (resp != null) {
|
|
_playActivityAudio(resp);
|
|
AnalyticsPractice.bypassExitConfirmation = false;
|
|
activityState.value = AsyncState.loaded(resp);
|
|
} else {
|
|
await _completeSession();
|
|
}
|
|
} catch (e) {
|
|
AnalyticsPractice.bypassExitConfirmation = true;
|
|
activityState.value = AsyncState.error(e);
|
|
}
|
|
}
|
|
|
|
Future<void> onSelectChoice(String choiceContent) async {
|
|
final activity = this.activity;
|
|
if (activity == null) return;
|
|
|
|
// Mark this choice as clicked so it can't be clicked again
|
|
if (notifier.hasSelectedChoice(choiceContent)) return;
|
|
notifier.selectChoice(choiceContent);
|
|
|
|
final uses = activity.constructUses(choiceContent);
|
|
_sessionController.submitAnswer(uses);
|
|
await _analyticsController.addCompletedActivityAnalytics(
|
|
uses,
|
|
AnalyticsPracticeUiController.getChoiceTargetId(
|
|
choiceContent,
|
|
widget.type,
|
|
),
|
|
_l2!.langCodeShort,
|
|
);
|
|
|
|
if (!notifier.activityComplete(activity)) return;
|
|
|
|
_playActivityAudio(activity);
|
|
|
|
if (_autoLaunchNextActivity(activity)) {
|
|
await Future.delayed(
|
|
const Duration(milliseconds: 1000),
|
|
startNextActivity,
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> startNextActivity() async {
|
|
_sessionController.completeActivity();
|
|
progress.value = _sessionController.progress;
|
|
await _continueSession();
|
|
}
|
|
|
|
Future<void> skipActivity(PracticeTarget target) async {
|
|
// Record a 0 XP use so that activity isn't chosen again soon
|
|
_sessionController.skipActivity();
|
|
progress.value = _sessionController.progress;
|
|
|
|
await _analyticsController.addSkippedActivityAnalytics(
|
|
target,
|
|
_l2!.langCodeShort,
|
|
);
|
|
}
|
|
|
|
Future<void> flagActivity(
|
|
MultipleChoicePracticeActivityModel activity,
|
|
) async {
|
|
final feedback = await showDialog<String?>(
|
|
context: context,
|
|
builder: (context) {
|
|
return FeedbackDialog(
|
|
title: L10n.of(context).feedbackTitle,
|
|
onSubmit: Navigator.of(context).pop,
|
|
scrollable: false,
|
|
);
|
|
},
|
|
);
|
|
|
|
if (feedback == null || feedback.isEmpty) return;
|
|
ErrorHandler.logError(
|
|
e: 'Practice activity flagged',
|
|
data: {'activity': activity.toJson(), 'feedback': feedback},
|
|
);
|
|
|
|
await skipActivity(activity.practiceTarget);
|
|
await _continueSession();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) => AnalyticsPracticeView(this);
|
|
}
|