5720 vocab practice should have feedback flag (#5761)
* chore: split up analytics activity page widgets into separate files * started analytics practice refactor * refactor how UI updates are triggered in analytics practice page * some fixes
This commit is contained in:
parent
d4884e6215
commit
117a03089e
24 changed files with 1872 additions and 1735 deletions
120
lib/pangea/analytics_practice/activity_choice_card_widget.dart
Normal file
120
lib/pangea/analytics_practice/activity_choice_card_widget.dart
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_practice/choice_cards/audio_choice_card.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/choice_cards/grammar_choice_card.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/choice_cards/meaning_choice_card.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class ActivityChoiceCard extends StatelessWidget {
|
||||
final MultipleChoicePracticeActivityModel activity;
|
||||
final String choiceId;
|
||||
final String targetId;
|
||||
final VoidCallback onPressed;
|
||||
final double cardHeight;
|
||||
|
||||
final String choiceText;
|
||||
final String? choiceEmoji;
|
||||
final bool enabled;
|
||||
final bool shrinkWrap;
|
||||
final bool showHint;
|
||||
|
||||
const ActivityChoiceCard({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.choiceId,
|
||||
required this.targetId,
|
||||
required this.onPressed,
|
||||
required this.cardHeight,
|
||||
required this.choiceText,
|
||||
required this.choiceEmoji,
|
||||
required this.showHint,
|
||||
this.enabled = true,
|
||||
this.shrinkWrap = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCorrect = activity.multipleChoiceContent.isCorrect(choiceId);
|
||||
final activityType = activity.activityType;
|
||||
final constructId = activity.tokens.first.vocabConstructID;
|
||||
|
||||
switch (activity.activityType) {
|
||||
case ActivityTypeEnum.lemmaMeaning:
|
||||
return MeaningChoiceCard(
|
||||
key: ValueKey(
|
||||
'${constructId.string}_${activityType.name}_meaning_$choiceId',
|
||||
),
|
||||
choiceId: choiceId,
|
||||
targetId: targetId,
|
||||
displayText: choiceText,
|
||||
emoji: choiceEmoji,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: cardHeight,
|
||||
isEnabled: enabled,
|
||||
);
|
||||
|
||||
case ActivityTypeEnum.lemmaAudio:
|
||||
return AudioChoiceCard(
|
||||
key: ValueKey(
|
||||
'${constructId.string}_${activityType.name}_audio_$choiceId',
|
||||
),
|
||||
choiceId: choiceId,
|
||||
targetId: targetId,
|
||||
displayText: choiceText,
|
||||
textLanguage: MatrixState.pangeaController.userController.userL2!,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
isEnabled: enabled,
|
||||
showHint: showHint,
|
||||
);
|
||||
|
||||
case ActivityTypeEnum.grammarCategory:
|
||||
return GrammarChoiceCard(
|
||||
key: ValueKey(
|
||||
'${constructId.string}_${activityType.name}_grammar_$choiceId',
|
||||
),
|
||||
choiceId: choiceId,
|
||||
targetId: targetId,
|
||||
feature: (activity as MorphPracticeActivityModel).morphFeature,
|
||||
tag: choiceText,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: cardHeight,
|
||||
enabled: enabled,
|
||||
);
|
||||
|
||||
case ActivityTypeEnum.grammarError:
|
||||
final activity = this.activity as GrammarErrorPracticeActivityModel;
|
||||
return GameChoiceCard(
|
||||
key: ValueKey(
|
||||
'${activity.errorLength}_${activity.errorOffset}_${activity.eventID}_${activityType.name}_grammar_error_$choiceId',
|
||||
),
|
||||
shouldFlip: false,
|
||||
targetId: targetId,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: cardHeight,
|
||||
isEnabled: enabled,
|
||||
child: Text(choiceText),
|
||||
);
|
||||
|
||||
default:
|
||||
return GameChoiceCard(
|
||||
key: ValueKey(
|
||||
'${constructId.string}_${activityType.name}_basic_$choiceId',
|
||||
),
|
||||
shouldFlip: false,
|
||||
targetId: targetId,
|
||||
onPressed: onPressed,
|
||||
isCorrect: isCorrect,
|
||||
height: cardHeight,
|
||||
isEnabled: enabled,
|
||||
child: Text(choiceText),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
225
lib/pangea/analytics_practice/activity_choices_widget.dart
Normal file
225
lib/pangea/analytics_practice/activity_choices_widget.dart
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/setting_keys.dart';
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/activity_choice_card_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_data_service.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_ui_controller.dart';
|
||||
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class ActivityChoices extends StatelessWidget {
|
||||
final MultipleChoicePracticeActivityModel activity;
|
||||
final List<AnalyticsPracticeChoice> choices;
|
||||
final ConstructTypeEnum type;
|
||||
|
||||
final bool isComplete;
|
||||
final bool showHint;
|
||||
final Function(String) onSelectChoice;
|
||||
|
||||
final List<InlineSpan>? audioExampleMessage;
|
||||
final String? audioTranslation;
|
||||
|
||||
const ActivityChoices({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.choices,
|
||||
required this.type,
|
||||
required this.isComplete,
|
||||
required this.showHint,
|
||||
required this.onSelectChoice,
|
||||
this.audioExampleMessage,
|
||||
this.audioTranslation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (activity.activityType == ActivityTypeEnum.lemmaAudio) {
|
||||
// For audio activities, use AnimatedSwitcher to fade between choices and example message
|
||||
return AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
child: isComplete
|
||||
? _AudioCompletionWidget(
|
||||
key: const ValueKey('completion'),
|
||||
showHint: showHint,
|
||||
exampleMessage: audioExampleMessage ?? [],
|
||||
translation: audioTranslation ?? "",
|
||||
)
|
||||
: Padding(
|
||||
key: const ValueKey('choices'),
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Wrap(
|
||||
alignment: WrapAlignment.center,
|
||||
spacing: 8.0,
|
||||
runSpacing: 8.0,
|
||||
children: choices
|
||||
.map(
|
||||
(choice) => ActivityChoiceCard(
|
||||
activity: activity,
|
||||
targetId:
|
||||
AnalyticsPracticeUiController.getChoiceTargetId(
|
||||
choice.choiceId,
|
||||
type,
|
||||
),
|
||||
choiceId: choice.choiceId,
|
||||
onPressed: () => onSelectChoice(choice.choiceId),
|
||||
cardHeight: 48.0,
|
||||
showHint: showHint,
|
||||
choiceText: choice.choiceText,
|
||||
choiceEmoji: choice.choiceEmoji,
|
||||
enabled: !isComplete,
|
||||
shrinkWrap: true,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Column(
|
||||
spacing: 8.0,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: choices
|
||||
.map(
|
||||
(choice) => ActivityChoiceCard(
|
||||
activity: activity,
|
||||
targetId: AnalyticsPracticeUiController.getChoiceTargetId(
|
||||
choice.choiceId,
|
||||
type,
|
||||
),
|
||||
choiceId: choice.choiceId,
|
||||
onPressed: () => onSelectChoice(choice.choiceId),
|
||||
cardHeight: 60.0,
|
||||
showHint: showHint,
|
||||
choiceText: choice.choiceText,
|
||||
choiceEmoji: choice.choiceEmoji,
|
||||
enabled: !isComplete,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AudioCompletionWidget extends StatelessWidget {
|
||||
final List<InlineSpan> exampleMessage;
|
||||
final String translation;
|
||||
final bool showHint;
|
||||
|
||||
const _AudioCompletionWidget({
|
||||
super.key,
|
||||
required this.exampleMessage,
|
||||
required this.translation,
|
||||
required this.showHint,
|
||||
});
|
||||
|
||||
String _extractTextFromSpans(List<InlineSpan> spans) {
|
||||
final buffer = StringBuffer();
|
||||
for (final span in spans) {
|
||||
if (span is TextSpan && span.text != null) {
|
||||
buffer.write(span.text);
|
||||
}
|
||||
}
|
||||
return buffer.toString();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (exampleMessage.isEmpty) {
|
||||
return const SizedBox(height: 100.0);
|
||||
}
|
||||
|
||||
final exampleText = _extractTextFromSpans(exampleMessage);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.alphaBlend(
|
||||
Colors.white.withAlpha(180),
|
||||
ThemeData.dark().colorScheme.primary,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedSize(
|
||||
duration: FluffyThemes.animationDuration,
|
||||
alignment: Alignment.topCenter,
|
||||
child: showHint
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: PhoneticTranscriptionWidget(
|
||||
text: exampleText,
|
||||
pos: 'other',
|
||||
textLanguage:
|
||||
MatrixState.pangeaController.userController.userL2!,
|
||||
textOnly: true,
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryFixed.withValues(alpha: 0.7),
|
||||
fontSize:
|
||||
(AppSettings.fontSizeFactor.value *
|
||||
AppConfig.messageFontSize) *
|
||||
0.85,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
maxLines: 2,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
// Main example message
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryFixed,
|
||||
fontSize:
|
||||
AppSettings.fontSizeFactor.value *
|
||||
AppConfig.messageFontSize,
|
||||
),
|
||||
children: exampleMessage,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: _AudioCompletionTranslation(translation: translation),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Widget to show translation for audio completion message
|
||||
class _AudioCompletionTranslation extends StatelessWidget {
|
||||
final String translation;
|
||||
|
||||
const _AudioCompletionTranslation({required this.translation});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Text(
|
||||
translation,
|
||||
style: TextStyle(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onPrimaryFixed.withValues(alpha: 0.8),
|
||||
fontSize:
|
||||
(AppSettings.fontSizeFactor.value * AppConfig.messageFontSize) *
|
||||
0.9,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
68
lib/pangea/analytics_practice/activity_content_widget.dart
Normal file
68
lib/pangea/analytics_practice/activity_content_widget.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/setting_keys.dart';
|
||||
import 'package:fluffychat/pages/chat/events/audio_player.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/activity_example_message_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/grammar_error_example_widget.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class ActivityContent extends StatelessWidget {
|
||||
final MultipleChoicePracticeActivityModel activity;
|
||||
|
||||
final bool showHint;
|
||||
final Future<List<InlineSpan>?> exampleMessage;
|
||||
final PangeaAudioFile? audioFile;
|
||||
|
||||
const ActivityContent({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.showHint,
|
||||
required this.exampleMessage,
|
||||
this.audioFile,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activity = this.activity;
|
||||
|
||||
return switch (activity) {
|
||||
GrammarErrorPracticeActivityModel() => SingleChildScrollView(
|
||||
child: GrammarErrorExampleWidget(
|
||||
key: ValueKey(
|
||||
'${activity.eventID}_${activity.errorOffset}_${activity.errorLength}',
|
||||
),
|
||||
activity: activity,
|
||||
showTranslation: showHint,
|
||||
),
|
||||
),
|
||||
MorphCategoryPracticeActivityModel() => Center(
|
||||
child: ActivityExampleMessage(exampleMessage),
|
||||
),
|
||||
VocabAudioPracticeActivityModel() => SizedBox(
|
||||
height: 60.0,
|
||||
child: Center(
|
||||
child: AudioPlayerWidget(
|
||||
null,
|
||||
key: ValueKey('audio_${activity.eventId}'),
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
linkColor: Theme.of(context).colorScheme.secondary,
|
||||
fontSize:
|
||||
AppSettings.fontSizeFactor.value * AppConfig.messageFontSize,
|
||||
eventId: '${activity.eventId}_practice',
|
||||
roomId: activity.roomId!,
|
||||
senderId: Matrix.of(context).client.userID!,
|
||||
matrixFile: audioFile,
|
||||
autoplay: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
_ => SizedBox(
|
||||
height: 100.0,
|
||||
child: Center(child: ActivityExampleMessage(exampleMessage)),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/setting_keys.dart';
|
||||
|
||||
class ActivityExampleMessage extends StatelessWidget {
|
||||
final Future<List<InlineSpan>?> future;
|
||||
|
||||
const ActivityExampleMessage(this.future, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<List<InlineSpan>?>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData || snapshot.data == null) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.alphaBlend(
|
||||
Colors.white.withAlpha(180),
|
||||
ThemeData.dark().colorScheme.primary,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryFixed,
|
||||
fontSize:
|
||||
AppSettings.fontSizeFactor.value *
|
||||
AppConfig.messageFontSize,
|
||||
),
|
||||
children: snapshot.data!,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
36
lib/pangea/analytics_practice/activity_feedback_widget.dart
Normal file
36
lib/pangea/analytics_practice/activity_feedback_widget.dart
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
|
||||
class ActivityFeedback extends StatelessWidget {
|
||||
final MultipleChoicePracticeActivityModel activity;
|
||||
final SelectedMorphChoice selectedChoice;
|
||||
|
||||
const ActivityFeedback({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.selectedChoice,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isWrongAnswer = !activity.multipleChoiceContent.isCorrect(
|
||||
selectedChoice.tag,
|
||||
);
|
||||
|
||||
if (!isWrongAnswer) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: MorphMeaningWidget(
|
||||
feature: selectedChoice.feature,
|
||||
tag: selectedChoice.tag,
|
||||
blankErrorFeedback: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/common/widgets/pressable_button.dart';
|
||||
|
||||
class HintButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
final bool depressed;
|
||||
final IconData icon;
|
||||
|
||||
const HintButton({
|
||||
required this.onPressed,
|
||||
required this.depressed,
|
||||
super.key,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return PressableButton(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
onPressed: onPressed,
|
||||
depressed: depressed,
|
||||
playSound: true,
|
||||
colorFactor: 0.3,
|
||||
builder: (context, depressed, shadowColor) => Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
height: 40.0,
|
||||
width: 40.0,
|
||||
decoration: BoxDecoration(
|
||||
color: depressed
|
||||
? shadowColor
|
||||
: Theme.of(context).colorScheme.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
Icon(icon, size: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_details_popup/morph_meaning_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/activity_hint_button_widget.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
|
||||
class ActivityHintSection extends StatelessWidget {
|
||||
final MultipleChoicePracticeActivityModel activity;
|
||||
final VoidCallback onPressed;
|
||||
final bool enabled;
|
||||
final bool hintPressed;
|
||||
|
||||
const ActivityHintSection({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.onPressed,
|
||||
required this.enabled,
|
||||
required this.hintPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activity = this.activity;
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(minHeight: 50.0),
|
||||
child: switch (activity) {
|
||||
VocabAudioPracticeActivityModel() => HintButton(
|
||||
onPressed: onPressed,
|
||||
depressed: hintPressed,
|
||||
icon: Symbols.text_to_speech,
|
||||
),
|
||||
MorphPracticeActivityModel() => AnimatedCrossFade(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
crossFadeState: hintPressed
|
||||
? CrossFadeState.showSecond
|
||||
: CrossFadeState.showFirst,
|
||||
firstChild: HintButton(
|
||||
icon: Icons.lightbulb_outline,
|
||||
onPressed: enabled ? onPressed : () {},
|
||||
depressed: !enabled,
|
||||
),
|
||||
secondChild: MorphMeaningWidget(
|
||||
feature: activity.morphFeature,
|
||||
tag: activity.multipleChoiceContent.answers.first,
|
||||
),
|
||||
),
|
||||
GrammarErrorPracticeActivityModel() => HintButton(
|
||||
icon: Icons.lightbulb_outline,
|
||||
onPressed: !enabled ? () {} : onPressed,
|
||||
depressed: hintPressed || !enabled,
|
||||
),
|
||||
_ => SizedBox(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
||||
|
||||
class ActivityHintsProgress extends StatelessWidget {
|
||||
final int hintsUsed;
|
||||
|
||||
const ActivityHintsProgress({super.key, required this.hintsUsed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
AnalyticsPracticeConstants.maxHints,
|
||||
(index) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4.0),
|
||||
child: Icon(
|
||||
index < hintsUsed ? Icons.lightbulb : Icons.lightbulb_outline,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,78 @@
|
|||
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class AnalyticsPracticeAnalyticsController {
|
||||
final AnalyticsDataService analyticsService;
|
||||
|
||||
const AnalyticsPracticeAnalyticsController(this.analyticsService);
|
||||
|
||||
Future<double> levelProgress(String language) async {
|
||||
final derviedData = await analyticsService.derivedData(language);
|
||||
return derviedData.levelProgress;
|
||||
}
|
||||
|
||||
Future<void> addCompletedActivityAnalytics(
|
||||
List<OneConstructUse> uses,
|
||||
String targetId,
|
||||
String language,
|
||||
) => analyticsService.updateService.addAnalytics(targetId, uses, language);
|
||||
|
||||
Future<void> addSkippedActivityAnalytics(
|
||||
PangeaToken token,
|
||||
ConstructTypeEnum type,
|
||||
String language,
|
||||
) async {
|
||||
final use = OneConstructUse(
|
||||
useType: ConstructUseTypeEnum.ignPA,
|
||||
constructType: type,
|
||||
metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()),
|
||||
category: token.pos,
|
||||
lemma: token.lemma.text,
|
||||
form: token.lemma.text,
|
||||
xp: 0,
|
||||
);
|
||||
|
||||
await analyticsService.updateService.addAnalytics(null, [use], language);
|
||||
}
|
||||
|
||||
Future<void> addSessionAnalytics(
|
||||
List<OneConstructUse> uses,
|
||||
String language,
|
||||
) async {
|
||||
await analyticsService.updateService.addAnalytics(
|
||||
null,
|
||||
uses,
|
||||
language,
|
||||
forceUpdate: true,
|
||||
);
|
||||
}
|
||||
|
||||
Future<ConstructUses> getTargetTokenConstruct(
|
||||
PracticeTarget target,
|
||||
String language,
|
||||
) async {
|
||||
final token = target.tokens.first;
|
||||
final construct = target.targetTokenConstructID(token);
|
||||
return analyticsService.getConstructUse(construct, language);
|
||||
}
|
||||
|
||||
Future<void> waitForAnalytics() async {
|
||||
if (!analyticsService.initCompleter.isCompleted) {
|
||||
MatrixState.pangeaController.initControllers();
|
||||
await analyticsService.initCompleter.future;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> waitForUpdate() => analyticsService
|
||||
.updateDispatcher
|
||||
.constructUpdateStream
|
||||
.stream
|
||||
.first
|
||||
.timeout(const Duration(seconds: 10));
|
||||
}
|
||||
|
|
@ -2,5 +2,6 @@ class AnalyticsPracticeConstants {
|
|||
static const int timeForBonus = 60;
|
||||
static const int practiceGroupSize = 10;
|
||||
static const int errorBufferSize = 5;
|
||||
static const int maxHints = 5;
|
||||
static int get targetsToGenerate => practiceGroupSize + errorBufferSize;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,210 @@
|
|||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart';
|
||||
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class AnalyticsPracticeChoice {
|
||||
final String choiceId;
|
||||
final String choiceText;
|
||||
final String? choiceEmoji;
|
||||
|
||||
const AnalyticsPracticeChoice({
|
||||
required this.choiceId,
|
||||
required this.choiceText,
|
||||
this.choiceEmoji,
|
||||
});
|
||||
}
|
||||
|
||||
class AnalyticsPracticeDataService {
|
||||
final Map<String, Map<String, String>> _choiceTexts = {};
|
||||
final Map<String, Map<String, String?>> _choiceEmojis = {};
|
||||
final Map<String, PangeaAudioFile> _audioFiles = {};
|
||||
final Map<String, String> _audioTranslations = {};
|
||||
|
||||
void clear() {
|
||||
_choiceTexts.clear();
|
||||
_choiceEmojis.clear();
|
||||
_audioFiles.clear();
|
||||
_audioTranslations.clear();
|
||||
}
|
||||
|
||||
String _getChoiceText(String key, String choiceId, ConstructTypeEnum type) {
|
||||
if (type == ConstructTypeEnum.morph) {
|
||||
return choiceId;
|
||||
}
|
||||
if (_choiceTexts.containsKey(key) &&
|
||||
_choiceTexts[key]!.containsKey(choiceId)) {
|
||||
return _choiceTexts[key]![choiceId]!;
|
||||
}
|
||||
final cId = ConstructIdentifier.fromString(choiceId);
|
||||
return cId?.lemma ?? choiceId;
|
||||
}
|
||||
|
||||
String? _getChoiceEmoji(String key, String choiceId, ConstructTypeEnum type) {
|
||||
if (type == ConstructTypeEnum.morph) return null;
|
||||
return _choiceEmojis[key]?[choiceId];
|
||||
}
|
||||
|
||||
PangeaAudioFile? getAudioFile(MultipleChoicePracticeActivityModel activity) {
|
||||
if (activity is! VocabAudioPracticeActivityModel) return null;
|
||||
if (activity.eventId == null) return null;
|
||||
return _audioFiles[activity.eventId];
|
||||
}
|
||||
|
||||
String? getAudioTranslation(MultipleChoicePracticeActivityModel activity) {
|
||||
if (activity is! VocabAudioPracticeActivityModel) return null;
|
||||
if (activity.eventId == null) return null;
|
||||
final translation = _audioTranslations[activity.eventId];
|
||||
return translation;
|
||||
}
|
||||
|
||||
List<AnalyticsPracticeChoice> filteredChoices(
|
||||
MultipleChoicePracticeActivityModel activity,
|
||||
ConstructTypeEnum type,
|
||||
) {
|
||||
final content = activity.multipleChoiceContent;
|
||||
final choices = content.choices.toList();
|
||||
final answer = content.answers.first;
|
||||
final filtered = <AnalyticsPracticeChoice>[];
|
||||
|
||||
final seenTexts = <String>{};
|
||||
for (final id in choices) {
|
||||
final text = _getChoiceText(activity.storageKey, id, type);
|
||||
|
||||
if (seenTexts.contains(text)) {
|
||||
if (id != answer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final index = filtered.indexWhere(
|
||||
(choice) => choice.choiceText == text,
|
||||
);
|
||||
if (index != -1) {
|
||||
filtered[index] = AnalyticsPracticeChoice(
|
||||
choiceId: id,
|
||||
choiceText: text,
|
||||
choiceEmoji: _getChoiceEmoji(activity.storageKey, id, type),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
seenTexts.add(text);
|
||||
filtered.add(
|
||||
AnalyticsPracticeChoice(
|
||||
choiceId: id,
|
||||
choiceText: text,
|
||||
choiceEmoji: _getChoiceEmoji(activity.storageKey, id, type),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
void _setLemmaInfo(
|
||||
String requestKey,
|
||||
Map<String, String> texts,
|
||||
Map<String, String?> emojis,
|
||||
) {
|
||||
_choiceTexts.putIfAbsent(requestKey, () => {});
|
||||
_choiceEmojis.putIfAbsent(requestKey, () => {});
|
||||
|
||||
_choiceTexts[requestKey]!.addAll(texts);
|
||||
_choiceEmojis[requestKey]!.addAll(emojis);
|
||||
}
|
||||
|
||||
void _setAudioInfo(
|
||||
String eventId,
|
||||
PangeaAudioFile audioFile,
|
||||
String translation,
|
||||
) {
|
||||
_audioFiles[eventId] = audioFile;
|
||||
_audioTranslations[eventId] = translation;
|
||||
}
|
||||
|
||||
Future<void> prefetchActivityInfo(
|
||||
MultipleChoicePracticeActivityModel activity,
|
||||
) async {
|
||||
// Prefetch lemma info for meaning activities before marking ready
|
||||
if (activity is VocabMeaningPracticeActivityModel) {
|
||||
final choices = activity.multipleChoiceContent.choices.toList();
|
||||
await _prefetchLemmaInfo(activity.storageKey, choices);
|
||||
}
|
||||
|
||||
// Prefetch audio for audio activities before marking ready
|
||||
if (activity is VocabAudioPracticeActivityModel) {
|
||||
await _prefetchAudioInfo(activity);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _prefetchAudioInfo(
|
||||
VocabAudioPracticeActivityModel activity,
|
||||
) async {
|
||||
final eventId = activity.eventId;
|
||||
final roomId = activity.roomId;
|
||||
if (eventId == null || roomId == null) {
|
||||
throw Exception();
|
||||
}
|
||||
|
||||
final client = MatrixState.pangeaController.matrixState.client;
|
||||
final room = client.getRoomById(roomId);
|
||||
|
||||
if (room == null) {
|
||||
throw Exception();
|
||||
}
|
||||
|
||||
final event = await room.getEventById(eventId);
|
||||
if (event == null) {
|
||||
throw Exception();
|
||||
}
|
||||
|
||||
final pangeaEvent = PangeaMessageEvent(
|
||||
event: event,
|
||||
timeline: await room.getTimeline(),
|
||||
ownMessage: event.senderId == client.userID,
|
||||
);
|
||||
|
||||
// Prefetch the audio file
|
||||
final audioFile = await pangeaEvent.requestTextToSpeech(
|
||||
activity.langCode,
|
||||
MatrixState.pangeaController.userController.voice,
|
||||
);
|
||||
|
||||
if (audioFile.duration == null || audioFile.duration! <= 2000) {
|
||||
throw "Audio file too short";
|
||||
}
|
||||
|
||||
// Prefetch the translation
|
||||
final translation = await pangeaEvent.requestRespresentationByL1();
|
||||
_setAudioInfo(eventId, audioFile, translation);
|
||||
}
|
||||
|
||||
Future<void> _prefetchLemmaInfo(
|
||||
String requestKey,
|
||||
List<String> choiceIds,
|
||||
) async {
|
||||
final texts = <String, String>{};
|
||||
final emojis = <String, String?>{};
|
||||
|
||||
for (final id in choiceIds) {
|
||||
final cId = ConstructIdentifier.fromString(id);
|
||||
if (cId == null) continue;
|
||||
|
||||
final res = await cId.getLemmaInfo({});
|
||||
if (res.isError) {
|
||||
LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({}));
|
||||
throw Exception();
|
||||
}
|
||||
|
||||
texts[id] = res.result!.meaning;
|
||||
emojis[id] = res.result!.emoji.firstOrNull;
|
||||
}
|
||||
|
||||
_setLemmaInfo(requestKey, texts, emojis);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +1,21 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
|
||||
import 'package:fluffychat/pangea/analytics_data/analytics_updater_mixin.dart';
|
||||
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.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_session_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.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/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/languages/language_model.dart';
|
||||
import 'package:fluffychat/pangea/lemmas/lemma_info_repo.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
|
||||
import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/message_practice/message_audio_card.dart';
|
||||
import 'package:fluffychat/pangea/toolbar/message_practice/practice_record_controller.dart';
|
||||
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class SelectedMorphChoice {
|
||||
|
|
@ -40,38 +25,68 @@ class SelectedMorphChoice {
|
|||
const SelectedMorphChoice({required this.feature, required this.tag});
|
||||
}
|
||||
|
||||
class VocabPracticeChoice {
|
||||
final String choiceId;
|
||||
final String choiceText;
|
||||
final String? choiceEmoji;
|
||||
class AnalyticsPracticeNotifier extends ChangeNotifier {
|
||||
String? _lastSelectedChoice;
|
||||
bool showHint = false;
|
||||
final Set<String> _clickedChoices = {};
|
||||
|
||||
const VocabPracticeChoice({
|
||||
required this.choiceId,
|
||||
required this.choiceText,
|
||||
this.choiceEmoji,
|
||||
});
|
||||
}
|
||||
int correctAnswersSelected(MultipleChoicePracticeActivityModel? activity) {
|
||||
if (activity == null) return 0;
|
||||
final allAnswers = activity.multipleChoiceContent.answers;
|
||||
return _clickedChoices.where((c) => allAnswers.contains(c)).length;
|
||||
}
|
||||
|
||||
class _PracticeQueueEntry {
|
||||
final MessageActivityRequest request;
|
||||
final Completer<MultipleChoicePracticeActivityModel> completer;
|
||||
bool enableHintPress(
|
||||
MultipleChoicePracticeActivityModel? activity,
|
||||
int hintsUsed,
|
||||
) {
|
||||
if (showHint) return false;
|
||||
return switch (activity) {
|
||||
VocabAudioPracticeActivityModel() => true,
|
||||
_ => hintsUsed < AnalyticsPracticeConstants.maxHints,
|
||||
};
|
||||
}
|
||||
|
||||
_PracticeQueueEntry({required this.request, required this.completer});
|
||||
}
|
||||
SelectedMorphChoice? selectedMorphChoice(
|
||||
MultipleChoicePracticeActivityModel? activity,
|
||||
) {
|
||||
if (activity is! MorphPracticeActivityModel) return null;
|
||||
if (_lastSelectedChoice == null) return null;
|
||||
return SelectedMorphChoice(
|
||||
feature: activity.morphFeature,
|
||||
tag: _lastSelectedChoice!,
|
||||
);
|
||||
}
|
||||
|
||||
class SessionLoader extends AsyncLoader<AnalyticsPracticeSessionModel> {
|
||||
final ConstructTypeEnum type;
|
||||
SessionLoader({required this.type});
|
||||
bool activityComplete(MultipleChoicePracticeActivityModel? activity) {
|
||||
if (activity == null) return false;
|
||||
final allAnswers = activity.multipleChoiceContent.answers;
|
||||
return allAnswers.every((answer) => _clickedChoices.contains(answer));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<AnalyticsPracticeSessionModel> fetch() {
|
||||
final l2 =
|
||||
MatrixState.pangeaController.userController.userL2?.langCodeShort;
|
||||
if (l2 == null) throw Exception('User L2 language not set');
|
||||
return AnalyticsPracticeSessionRepo.get(type, l2);
|
||||
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;
|
||||
|
||||
|
|
@ -84,47 +99,96 @@ class AnalyticsPractice extends StatefulWidget {
|
|||
|
||||
class AnalyticsPracticeState extends State<AnalyticsPractice>
|
||||
with AnalyticsUpdater {
|
||||
late final SessionLoader _sessionLoader;
|
||||
final PracticeSessionController _sessionController =
|
||||
PracticeSessionController();
|
||||
|
||||
final ValueNotifier<AsyncState<MultipleChoicePracticeActivityModel>>
|
||||
activityState = ValueNotifier(const AsyncState.idle());
|
||||
|
||||
final Queue<_PracticeQueueEntry> _queue = Queue();
|
||||
|
||||
final ValueNotifier<MessageActivityRequest?> activityTarget =
|
||||
ValueNotifier<MessageActivityRequest?>(null);
|
||||
|
||||
final ValueNotifier<double> progressNotifier = ValueNotifier<double>(0.0);
|
||||
final ValueNotifier<bool> enableChoicesNotifier = ValueNotifier<bool>(true);
|
||||
|
||||
final ValueNotifier<SelectedMorphChoice?> selectedMorphChoice =
|
||||
ValueNotifier<SelectedMorphChoice?>(null);
|
||||
|
||||
final ValueNotifier<bool> hintPressedNotifier = ValueNotifier<bool>(false);
|
||||
|
||||
final Set<String> _clickedChoices = {};
|
||||
|
||||
// Track if we're showing the completion message for audio activities
|
||||
final ValueNotifier<bool> showingAudioCompletion = ValueNotifier<bool>(false);
|
||||
final ValueNotifier<int> hintsUsedNotifier = ValueNotifier<int>(0);
|
||||
static const int maxHints = 5;
|
||||
|
||||
// Track number of correct answers selected for audio activities (for progress ovals)
|
||||
final ValueNotifier<int> correctAnswersSelected = ValueNotifier<int>(0);
|
||||
|
||||
final Map<String, Map<String, String>> _choiceTexts = {};
|
||||
final Map<String, Map<String, String?>> _choiceEmojis = {};
|
||||
final Map<String, PangeaAudioFile> _audioFiles = {};
|
||||
final Map<String, String> _audioTranslations = {};
|
||||
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();
|
||||
_sessionLoader = SessionLoader(type: widget.type);
|
||||
_startSession();
|
||||
_languageStreamSubscription = MatrixState
|
||||
|
||||
_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
|
||||
|
|
@ -132,620 +196,130 @@ class AnalyticsPracticeState extends State<AnalyticsPractice>
|
|||
.listen((_) => _onLanguageUpdate());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_languageStreamSubscription?.cancel();
|
||||
_sessionLoader.dispose();
|
||||
activityState.dispose();
|
||||
activityTarget.dispose();
|
||||
progressNotifier.dispose();
|
||||
enableChoicesNotifier.dispose();
|
||||
selectedMorphChoice.dispose();
|
||||
hintPressedNotifier.dispose();
|
||||
showingAudioCompletion.dispose();
|
||||
hintsUsedNotifier.dispose();
|
||||
correctAnswersSelected.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
MultipleChoicePracticeActivityModel? get _currentActivity =>
|
||||
activityState.value is AsyncLoaded<MultipleChoicePracticeActivityModel>
|
||||
? (activityState.value
|
||||
as AsyncLoaded<MultipleChoicePracticeActivityModel>)
|
||||
.value
|
||||
: null;
|
||||
|
||||
bool get _isComplete => _sessionLoader.value?.isComplete ?? false;
|
||||
|
||||
ValueNotifier<AsyncState<AnalyticsPracticeSessionModel>> get sessionState =>
|
||||
_sessionLoader.state;
|
||||
|
||||
AnalyticsDataService get _analyticsService =>
|
||||
Matrix.of(context).analyticsDataService;
|
||||
|
||||
LanguageModel? get _l2 => MatrixState.pangeaController.userController.userL2;
|
||||
|
||||
List<VocabPracticeChoice> filteredChoices(
|
||||
MultipleChoicePracticeActivityModel activity,
|
||||
) {
|
||||
final content = activity.multipleChoiceContent;
|
||||
final choices = content.choices.toList();
|
||||
final answer = content.answers.first;
|
||||
final filtered = <VocabPracticeChoice>[];
|
||||
|
||||
final seenTexts = <String>{};
|
||||
for (final id in choices) {
|
||||
final text = getChoiceText(activity.storageKey, id);
|
||||
|
||||
if (seenTexts.contains(text)) {
|
||||
if (id != answer) {
|
||||
continue;
|
||||
}
|
||||
|
||||
final index = filtered.indexWhere(
|
||||
(choice) => choice.choiceText == text,
|
||||
);
|
||||
if (index != -1) {
|
||||
filtered[index] = VocabPracticeChoice(
|
||||
choiceId: id,
|
||||
choiceText: text,
|
||||
choiceEmoji: getChoiceEmoji(activity.storageKey, id),
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
seenTexts.add(text);
|
||||
filtered.add(
|
||||
VocabPracticeChoice(
|
||||
choiceId: id,
|
||||
choiceText: text,
|
||||
choiceEmoji: getChoiceEmoji(activity.storageKey, id),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
String getChoiceText(String key, String choiceId) {
|
||||
if (widget.type == ConstructTypeEnum.morph) {
|
||||
return choiceId;
|
||||
}
|
||||
if (_choiceTexts.containsKey(key) &&
|
||||
_choiceTexts[key]!.containsKey(choiceId)) {
|
||||
return _choiceTexts[key]![choiceId]!;
|
||||
}
|
||||
final cId = ConstructIdentifier.fromString(choiceId);
|
||||
return cId?.lemma ?? choiceId;
|
||||
}
|
||||
|
||||
String? getChoiceEmoji(String key, String choiceId) {
|
||||
if (widget.type == ConstructTypeEnum.morph) return null;
|
||||
return _choiceEmojis[key]?[choiceId];
|
||||
}
|
||||
|
||||
String choiceTargetId(String choiceId) =>
|
||||
'${widget.type.name}-choice-card-${choiceId.replaceAll(' ', '_')}';
|
||||
|
||||
void _clearState() {
|
||||
activityState.value = const AsyncState.loading();
|
||||
activityTarget.value = null;
|
||||
selectedMorphChoice.value = null;
|
||||
hintPressedNotifier.value = false;
|
||||
hintsUsedNotifier.value = 0;
|
||||
enableChoicesNotifier.value = true;
|
||||
progressNotifier.value = 0.0;
|
||||
showingAudioCompletion.value = false;
|
||||
_queue.clear();
|
||||
_choiceTexts.clear();
|
||||
_choiceEmojis.clear();
|
||||
_audioFiles.clear();
|
||||
_audioTranslations.clear();
|
||||
activityState.value = const AsyncState.idle();
|
||||
|
||||
AnalyticsPractice.bypassExitConfirmation = true;
|
||||
}
|
||||
|
||||
void updateElapsedTime(int seconds) {
|
||||
if (_sessionLoader.isLoaded) {
|
||||
_sessionLoader.value!.setElapsedSeconds(seconds);
|
||||
}
|
||||
}
|
||||
|
||||
void _playAudio() {
|
||||
if (activityTarget.value == null) return;
|
||||
if (widget.type == ConstructTypeEnum.vocab &&
|
||||
_currentActivity is VocabMeaningPracticeActivityModel) {
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
final token = activityTarget.value!.target.tokens.first;
|
||||
TtsController.tryToSpeak(
|
||||
token.vocabConstructID.lemma,
|
||||
langCode: _l2!.langCode,
|
||||
pos: token.pos,
|
||||
morph: token.morph.map((k, v) => MapEntry(k.name, v)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _waitForAnalytics() async {
|
||||
if (!_analyticsService.initCompleter.isCompleted) {
|
||||
MatrixState.pangeaController.initControllers();
|
||||
await _analyticsService.initCompleter.future;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onLanguageUpdate() async {
|
||||
try {
|
||||
_clearState();
|
||||
await _analyticsService
|
||||
.updateDispatcher
|
||||
.constructUpdateStream
|
||||
.stream
|
||||
.first
|
||||
.timeout(const Duration(seconds: 10));
|
||||
await reloadSession();
|
||||
await _analyticsController.waitForUpdate();
|
||||
await startSession();
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
activityState.value = AsyncState.error(
|
||||
L10n.of(context).oopsSomethingWentWrong,
|
||||
);
|
||||
activityState.value = AsyncState.error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startSession() async {
|
||||
await _waitForAnalytics();
|
||||
await _sessionLoader.load();
|
||||
if (_sessionLoader.isError) {
|
||||
AnalyticsPractice.bypassExitConfirmation = true;
|
||||
return;
|
||||
}
|
||||
|
||||
progressNotifier.value = _sessionLoader.value!.progress;
|
||||
await _continueSession();
|
||||
void onHintPressed({bool increment = true}) {
|
||||
if (increment) _sessionController.updateHintsPressed();
|
||||
notifier.toggleShowHint();
|
||||
}
|
||||
|
||||
Future<void> reloadSession() async {
|
||||
void _playActivityAudio(MultipleChoicePracticeActivityModel activity) =>
|
||||
AnalyticsPracticeUiController.playTargetAudio(
|
||||
activity,
|
||||
widget.type,
|
||||
_l2!.langCodeShort,
|
||||
);
|
||||
|
||||
Future<void> startSession() async {
|
||||
_clearState();
|
||||
_sessionLoader.reset();
|
||||
await _startSession();
|
||||
}
|
||||
await _analyticsController.waitForAnalytics();
|
||||
await _sessionController.startSession(widget.type);
|
||||
if (mounted) setState(() {});
|
||||
|
||||
Future<void> reloadCurrentActivity() async {
|
||||
if (activityTarget.value == null) return;
|
||||
|
||||
try {
|
||||
activityState.value = const AsyncState.loading();
|
||||
selectedMorphChoice.value = null;
|
||||
hintPressedNotifier.value = false;
|
||||
|
||||
final req = activityTarget.value!;
|
||||
final res = await _fetchActivity(req);
|
||||
|
||||
if (!mounted) return;
|
||||
activityState.value = AsyncState.loaded(res);
|
||||
_playAudio();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
activityState.value = AsyncState.error(e);
|
||||
if (_sessionController.sessionError != null) {
|
||||
AnalyticsPractice.bypassExitConfirmation = true;
|
||||
} else {
|
||||
progress.value = _sessionController.progress;
|
||||
await _continueSession();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _completeSession() async {
|
||||
_sessionLoader.value!.finishSession();
|
||||
_sessionController.completeSession();
|
||||
setState(() {});
|
||||
|
||||
final bonus = _sessionLoader.value!.state.allBonusUses;
|
||||
await _analyticsService.updateService.addAnalytics(
|
||||
null,
|
||||
bonus,
|
||||
_l2!.langCodeShort,
|
||||
forceUpdate: true,
|
||||
);
|
||||
final bonus = _sessionController.bonusUses;
|
||||
await _analyticsController.addSessionAnalytics(bonus, _l2!.langCodeShort);
|
||||
AnalyticsPractice.bypassExitConfirmation = true;
|
||||
}
|
||||
|
||||
bool _continuing = false;
|
||||
|
||||
Future<void> _continueSession() async {
|
||||
if (_continuing) return;
|
||||
_continuing = true;
|
||||
enableChoicesNotifier.value = true;
|
||||
showingAudioCompletion.value = false;
|
||||
correctAnswersSelected.value = 0;
|
||||
if (activityState.value
|
||||
is AsyncLoading<MultipleChoicePracticeActivityModel>) {
|
||||
return;
|
||||
}
|
||||
|
||||
_clearActivityState(loadingActivity: true);
|
||||
|
||||
try {
|
||||
if (activityState.value
|
||||
is AsyncIdle<MultipleChoicePracticeActivityModel>) {
|
||||
await _initActivityData();
|
||||
} else {
|
||||
// Keep trying to load activities from the queue until one succeeds or queue is empty
|
||||
while (_queue.isNotEmpty) {
|
||||
activityState.value = const AsyncState.loading();
|
||||
selectedMorphChoice.value = null;
|
||||
hintPressedNotifier.value = false;
|
||||
_clickedChoices.clear();
|
||||
final nextActivityCompleter = _queue.removeFirst();
|
||||
final resp = await _sessionController.getNextActivity(
|
||||
skipActivity,
|
||||
_dataService.prefetchActivityInfo,
|
||||
);
|
||||
|
||||
try {
|
||||
final activity = await nextActivityCompleter.completer.future;
|
||||
activityTarget.value = nextActivityCompleter.request;
|
||||
_playAudio();
|
||||
activityState.value = AsyncState.loaded(activity);
|
||||
AnalyticsPractice.bypassExitConfirmation = false;
|
||||
return;
|
||||
} catch (e) {
|
||||
// Completer failed, skip to next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Queue is empty, complete the session
|
||||
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);
|
||||
} finally {
|
||||
_continuing = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initActivityData() async {
|
||||
final requests = _sessionLoader.value!.activityRequests;
|
||||
if (requests.isEmpty) {
|
||||
throw L10n.of(context).noActivityRequest;
|
||||
}
|
||||
|
||||
for (var i = 0; i < requests.length; i++) {
|
||||
try {
|
||||
activityState.value = const AsyncState.loading();
|
||||
final req = requests[i];
|
||||
final res = await _fetchActivity(req);
|
||||
if (!mounted) return;
|
||||
activityTarget.value = req;
|
||||
_playAudio();
|
||||
activityState.value = AsyncState.loaded(res);
|
||||
AnalyticsPractice.bypassExitConfirmation = false;
|
||||
// Fill queue with remaining requests
|
||||
_fillActivityQueue(requests.skip(i + 1).toList());
|
||||
return;
|
||||
} catch (e) {
|
||||
await recordSkippedUse(requests[i]);
|
||||
// Try next request
|
||||
continue;
|
||||
}
|
||||
}
|
||||
AnalyticsPractice.bypassExitConfirmation = true;
|
||||
if (!mounted) return;
|
||||
activityState.value = AsyncState.error(
|
||||
L10n.of(context).oopsSomethingWentWrong,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Future<void> _fillActivityQueue(List<MessageActivityRequest> requests) async {
|
||||
for (final request in requests) {
|
||||
final completer = Completer<MultipleChoicePracticeActivityModel>();
|
||||
_queue.add(_PracticeQueueEntry(request: request, completer: completer));
|
||||
try {
|
||||
final res = await _fetchActivity(request);
|
||||
if (!mounted) return;
|
||||
completer.complete(res);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
completer.completeError(e);
|
||||
await recordSkippedUse(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<MultipleChoicePracticeActivityModel> _fetchActivity(
|
||||
MessageActivityRequest req,
|
||||
) async {
|
||||
final result = await PracticeRepo.getPracticeActivity(req, messageInfo: {});
|
||||
|
||||
if (result.isError ||
|
||||
result.result is! MultipleChoicePracticeActivityModel) {
|
||||
throw L10n.of(context).oopsSomethingWentWrong;
|
||||
}
|
||||
|
||||
final activityModel = result.result as MultipleChoicePracticeActivityModel;
|
||||
|
||||
// Prefetch lemma info for meaning activities before marking ready
|
||||
if (activityModel is VocabMeaningPracticeActivityModel) {
|
||||
final choices = activityModel.multipleChoiceContent.choices.toList();
|
||||
await _fetchLemmaInfo(activityModel.storageKey, choices);
|
||||
}
|
||||
|
||||
// Prefetch audio for audio activities before marking ready
|
||||
if (activityModel is VocabAudioPracticeActivityModel) {
|
||||
await _loadAudioForActivity(activityModel);
|
||||
}
|
||||
|
||||
return activityModel;
|
||||
}
|
||||
|
||||
Future<void> _loadAudioForActivity(
|
||||
VocabAudioPracticeActivityModel activity,
|
||||
) async {
|
||||
final eventId = activity.eventId;
|
||||
final roomId = activity.roomId;
|
||||
if (eventId == null || roomId == null) {
|
||||
throw L10n.of(context).oopsSomethingWentWrong;
|
||||
}
|
||||
|
||||
final client = MatrixState.pangeaController.matrixState.client;
|
||||
final room = client.getRoomById(roomId);
|
||||
|
||||
if (room == null) {
|
||||
throw L10n.of(context).oopsSomethingWentWrong;
|
||||
}
|
||||
|
||||
final event = await room.getEventById(eventId);
|
||||
if (event == null) {
|
||||
throw L10n.of(context).oopsSomethingWentWrong;
|
||||
}
|
||||
|
||||
final pangeaEvent = PangeaMessageEvent(
|
||||
event: event,
|
||||
timeline: await room.getTimeline(),
|
||||
ownMessage: event.senderId == client.userID,
|
||||
);
|
||||
|
||||
// Prefetch the audio file
|
||||
final audioFile = await pangeaEvent.requestTextToSpeech(
|
||||
activity.langCode,
|
||||
MatrixState.pangeaController.userController.voice,
|
||||
);
|
||||
|
||||
if (audioFile.duration == null || audioFile.duration! <= 2000) {
|
||||
throw "Audio file too short";
|
||||
}
|
||||
|
||||
// Prefetch the translation
|
||||
final translation = await pangeaEvent.requestRespresentationByL1();
|
||||
_audioFiles[eventId] = audioFile;
|
||||
_audioTranslations[eventId] = translation;
|
||||
}
|
||||
|
||||
PangeaAudioFile? getAudioFile(String? eventId) {
|
||||
if (eventId == null) return null;
|
||||
return _audioFiles[eventId];
|
||||
}
|
||||
|
||||
String? getAudioTranslation(String? eventId) {
|
||||
if (eventId == null) return null;
|
||||
final translation = _audioTranslations[eventId];
|
||||
return translation;
|
||||
}
|
||||
|
||||
Future<void> _fetchLemmaInfo(
|
||||
String requestKey,
|
||||
List<String> choiceIds,
|
||||
) async {
|
||||
final texts = <String, String>{};
|
||||
final emojis = <String, String?>{};
|
||||
|
||||
for (final id in choiceIds) {
|
||||
final cId = ConstructIdentifier.fromString(id);
|
||||
if (cId == null) continue;
|
||||
|
||||
final res = await cId.getLemmaInfo({});
|
||||
if (res.isError) {
|
||||
LemmaInfoRepo.clearCache(cId.lemmaInfoRequest({}));
|
||||
throw L10n.of(context).oopsSomethingWentWrong;
|
||||
}
|
||||
|
||||
texts[id] = res.result!.meaning;
|
||||
emojis[id] = res.result!.emoji.firstOrNull;
|
||||
}
|
||||
|
||||
_choiceTexts.putIfAbsent(requestKey, () => {});
|
||||
_choiceEmojis.putIfAbsent(requestKey, () => {});
|
||||
|
||||
_choiceTexts[requestKey]!.addAll(texts);
|
||||
_choiceEmojis[requestKey]!.addAll(emojis);
|
||||
}
|
||||
|
||||
Future<void> recordSkippedUse(MessageActivityRequest request) async {
|
||||
// Record a 0 XP use so that activity isn't chosen again soon
|
||||
_sessionLoader.value!.incrementSkippedActivities();
|
||||
final token = request.target.tokens.first;
|
||||
|
||||
final use = OneConstructUse(
|
||||
useType: ConstructUseTypeEnum.ignPA,
|
||||
constructType: widget.type,
|
||||
metadata: ConstructUseMetaData(roomId: null, timeStamp: DateTime.now()),
|
||||
category: token.pos,
|
||||
lemma: token.lemma.text,
|
||||
form: token.lemma.text,
|
||||
xp: 0,
|
||||
);
|
||||
|
||||
await _analyticsService.updateService.addAnalytics(null, [
|
||||
use,
|
||||
], _l2!.langCodeShort);
|
||||
}
|
||||
|
||||
void onHintPressed({bool increment = true}) {
|
||||
if (increment) {
|
||||
if (hintsUsedNotifier.value >= maxHints) return;
|
||||
if (!hintPressedNotifier.value) {
|
||||
hintsUsedNotifier.value++;
|
||||
}
|
||||
}
|
||||
hintPressedNotifier.value = !hintPressedNotifier.value;
|
||||
}
|
||||
|
||||
Future<void> onAudioContinuePressed() async {
|
||||
showingAudioCompletion.value = false;
|
||||
|
||||
//Mark this activity as completed, and either load the next or complete the session
|
||||
_sessionLoader.value!.completeActivity();
|
||||
progressNotifier.value = _sessionLoader.value!.progress;
|
||||
|
||||
if (_queue.isEmpty) {
|
||||
await _completeSession();
|
||||
} else if (_isComplete) {
|
||||
await _completeSession();
|
||||
} else {
|
||||
await _continueSession();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onSelectChoice(String choiceContent) async {
|
||||
if (_currentActivity == null) return;
|
||||
final activity = _currentActivity!;
|
||||
final activity = this.activity;
|
||||
if (activity == null) return;
|
||||
|
||||
// Mark this choice as clicked so it can't be clicked again
|
||||
if (_clickedChoices.contains(choiceContent)) {
|
||||
return;
|
||||
} else {
|
||||
setState(() {
|
||||
_clickedChoices.add(choiceContent);
|
||||
});
|
||||
}
|
||||
// Track the selection for display
|
||||
if (activity is MorphPracticeActivityModel) {
|
||||
selectedMorphChoice.value = SelectedMorphChoice(
|
||||
feature: activity.morphFeature,
|
||||
tag: choiceContent,
|
||||
);
|
||||
}
|
||||
|
||||
final isCorrect = activity.multipleChoiceContent.isCorrect(choiceContent);
|
||||
|
||||
final isAudioActivity =
|
||||
activity.activityType == ActivityTypeEnum.lemmaAudio;
|
||||
|
||||
if (isCorrect && !isAudioActivity) {
|
||||
// Non-audio activities disable choices after first correct answer
|
||||
enableChoicesNotifier.value = false;
|
||||
}
|
||||
|
||||
// Update activity record
|
||||
// For audio activities, find the token that matches the clicked word
|
||||
final tokenForChoice = isAudioActivity
|
||||
? activity.tokens.firstWhere(
|
||||
(t) => t.text.content.toLowerCase() == choiceContent.toLowerCase(),
|
||||
orElse: () => activity.tokens.first,
|
||||
)
|
||||
: activity.tokens.first;
|
||||
|
||||
PracticeRecordController.onSelectChoice(
|
||||
choiceContent,
|
||||
tokenForChoice,
|
||||
activity,
|
||||
);
|
||||
if (notifier.hasSelectedChoice(choiceContent)) return;
|
||||
notifier.selectChoice(choiceContent);
|
||||
|
||||
final uses = activity.constructUses(choiceContent);
|
||||
_sessionLoader.value!.submitAnswer(uses);
|
||||
await _analyticsService.updateService.addAnalytics(
|
||||
choiceTargetId(choiceContent),
|
||||
_sessionController.submitAnswer(uses);
|
||||
await _analyticsController.addCompletedActivityAnalytics(
|
||||
uses,
|
||||
AnalyticsPracticeUiController.getChoiceTargetId(
|
||||
choiceContent,
|
||||
widget.type,
|
||||
),
|
||||
_l2!.langCodeShort,
|
||||
);
|
||||
|
||||
if (!isCorrect) return;
|
||||
if (!notifier.activityComplete(activity)) return;
|
||||
|
||||
// For audio activities, check if all correct answers have been clicked
|
||||
if (isAudioActivity) {
|
||||
correctAnswersSelected.value++;
|
||||
final allAnswers = activity.multipleChoiceContent.answers;
|
||||
final allSelected = allAnswers.every(
|
||||
(answer) => _clickedChoices.contains(answer),
|
||||
_playActivityAudio(activity);
|
||||
|
||||
if (_autoLaunchNextActivity(activity)) {
|
||||
await Future.delayed(
|
||||
const Duration(milliseconds: 1000),
|
||||
startNextActivity,
|
||||
);
|
||||
|
||||
if (!allSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
// All answers selected, disable choices and show completion message
|
||||
enableChoicesNotifier.value = false;
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
showingAudioCompletion.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
_playAudio();
|
||||
|
||||
// Display the fact that the choice was correct before loading the next activity
|
||||
await Future.delayed(const Duration(milliseconds: 1000));
|
||||
|
||||
// Then mark this activity as completed, and either load the next or complete the session
|
||||
_sessionLoader.value!.completeActivity();
|
||||
progressNotifier.value = _sessionLoader.value!.progress;
|
||||
|
||||
if (_queue.isEmpty) {
|
||||
await _completeSession();
|
||||
} else if (_isComplete) {
|
||||
await _completeSession();
|
||||
} else {
|
||||
await _continueSession();
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<InlineSpan>?> getExampleMessage(
|
||||
MessageActivityRequest activityRequest,
|
||||
) async {
|
||||
final target = activityRequest.target;
|
||||
final token = target.tokens.first;
|
||||
final construct = target.targetTokenConstructID(token);
|
||||
Future<void> startNextActivity() async {
|
||||
_sessionController.completeActivity();
|
||||
progress.value = _sessionController.progress;
|
||||
|
||||
if (widget.type == ConstructTypeEnum.morph) {
|
||||
return activityRequest.exampleMessage?.exampleMessage;
|
||||
}
|
||||
_sessionController.session?.isComplete == true
|
||||
? await _completeSession()
|
||||
: await _continueSession();
|
||||
}
|
||||
|
||||
return ExampleMessageUtil.getExampleMessage(
|
||||
await _analyticsService.getConstructUse(construct, _l2!.langCodeShort),
|
||||
Future<void> skipActivity(MessageActivityRequest request) async {
|
||||
// Record a 0 XP use so that activity isn't chosen again soon
|
||||
_sessionController.skipActivity();
|
||||
await _analyticsController.addSkippedActivityAnalytics(
|
||||
request.target.tokens.first,
|
||||
widget.type,
|
||||
_l2!.langCodeShort,
|
||||
);
|
||||
}
|
||||
|
||||
List<InlineSpan>? getAudioExampleMessage() {
|
||||
final activity = _currentActivity;
|
||||
if (activity is VocabAudioPracticeActivityModel) {
|
||||
return activity.exampleMessage.exampleMessage;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<DerivedAnalyticsDataModel> get derivedAnalyticsData =>
|
||||
_analyticsService.derivedData(_l2!.langCodeShort);
|
||||
|
||||
/// Returns congratulations message based on performance
|
||||
String getCompletionMessage(BuildContext context) {
|
||||
final accuracy = _sessionLoader.value?.state.accuracy ?? 0;
|
||||
final hasTimeBonus =
|
||||
(_sessionLoader.value?.state.elapsedSeconds ?? 0) <=
|
||||
AnalyticsPracticeConstants.timeForBonus;
|
||||
final hintsUsed = hintsUsedNotifier.value;
|
||||
|
||||
final bool perfectAccuracy = accuracy == 100;
|
||||
final bool noHintsUsed = hintsUsed == 0;
|
||||
final bool hintsAvailable = widget.type == ConstructTypeEnum.morph;
|
||||
|
||||
//check how many conditions for bonuses the user met and return message accordingly
|
||||
final conditionsMet = [
|
||||
perfectAccuracy,
|
||||
!hintsAvailable || noHintsUsed,
|
||||
hasTimeBonus,
|
||||
].where((c) => c).length;
|
||||
|
||||
if (conditionsMet == 3) {
|
||||
return L10n.of(context).perfectPractice;
|
||||
}
|
||||
if (conditionsMet >= 2) {
|
||||
return L10n.of(context).greatPractice;
|
||||
}
|
||||
if (hintsAvailable && noHintsUsed) {
|
||||
return L10n.of(context).usedNoHints;
|
||||
}
|
||||
return L10n.of(context).youveCompletedPractice;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AnalyticsPracticeView(this);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
|
||||
import 'package:fluffychat/widgets/future_loading_dialog.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class _PracticeQueueEntry {
|
||||
final MessageActivityRequest request;
|
||||
final Completer<MultipleChoicePracticeActivityModel> completer;
|
||||
|
||||
_PracticeQueueEntry({required this.request, required this.completer});
|
||||
}
|
||||
|
||||
class PracticeSessionController {
|
||||
PracticeSessionController();
|
||||
|
||||
AnalyticsPracticeSessionModel? session;
|
||||
bool isLoadingSession = false;
|
||||
Object? sessionError;
|
||||
|
||||
final Queue<_PracticeQueueEntry> _queue = Queue();
|
||||
|
||||
void clear() {
|
||||
_queue.clear();
|
||||
}
|
||||
|
||||
List<MessageActivityRequest> get activityRequests =>
|
||||
session?.activityRequests ?? [];
|
||||
|
||||
List<OneConstructUse> get bonusUses => session?.state.allBonusUses ?? [];
|
||||
|
||||
int get hintsUsed => session?.state.hintsUsed ?? 0;
|
||||
|
||||
double get progress => session?.progress ?? 0;
|
||||
|
||||
String getCompletionMessage(BuildContext context) =>
|
||||
session?.getCompletionMessage(context) ??
|
||||
L10n.of(context).youveCompletedPractice;
|
||||
|
||||
void updateElapsedTime(int seconds) {
|
||||
session?.setElapsedSeconds(seconds);
|
||||
}
|
||||
|
||||
void updateHintsPressed() {
|
||||
session?.useHint();
|
||||
}
|
||||
|
||||
void updateElapsedSeconds(int seconds) {
|
||||
session?.setElapsedSeconds(seconds);
|
||||
}
|
||||
|
||||
void completeActivity() {
|
||||
session?.completeActivity();
|
||||
}
|
||||
|
||||
void skipActivity() {
|
||||
session?.incrementSkippedActivities();
|
||||
}
|
||||
|
||||
void submitAnswer(List<OneConstructUse> uses) {
|
||||
session?.submitAnswer(uses);
|
||||
}
|
||||
|
||||
Future<void> startSession(ConstructTypeEnum type) async {
|
||||
try {
|
||||
isLoadingSession = true;
|
||||
sessionError = null;
|
||||
session = null;
|
||||
|
||||
final l2 =
|
||||
MatrixState.pangeaController.userController.userL2?.langCodeShort;
|
||||
if (l2 == null) throw Exception('User L2 language not set');
|
||||
session = await AnalyticsPracticeSessionRepo.get(type, l2);
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(e: e, s: s, data: {});
|
||||
sessionError = e;
|
||||
} finally {
|
||||
isLoadingSession = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> completeSession() async {
|
||||
session?.finishSession();
|
||||
}
|
||||
|
||||
Future<MultipleChoicePracticeActivityModel?> _initActivityData(
|
||||
Future Function(MessageActivityRequest) onSkip,
|
||||
Future Function(MultipleChoicePracticeActivityModel) onFetch,
|
||||
) async {
|
||||
final requests = activityRequests;
|
||||
for (var i = 0; i < requests.length; i++) {
|
||||
try {
|
||||
final req = requests[i];
|
||||
final res = await _fetchActivity(req, onFetch);
|
||||
_fillActivityQueue(requests.skip(i + 1).toList(), onSkip, onFetch);
|
||||
return res;
|
||||
} catch (e) {
|
||||
await onSkip(requests[i]);
|
||||
// Try next request
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _fillActivityQueue(
|
||||
List<MessageActivityRequest> requests,
|
||||
Future Function(MessageActivityRequest) onSkip,
|
||||
Future Function(MultipleChoicePracticeActivityModel) onFetch,
|
||||
) async {
|
||||
for (final request in requests) {
|
||||
final completer = Completer<MultipleChoicePracticeActivityModel>();
|
||||
_queue.add(_PracticeQueueEntry(request: request, completer: completer));
|
||||
try {
|
||||
final res = await _fetchActivity(request, onFetch);
|
||||
completer.complete(res);
|
||||
} catch (e) {
|
||||
completer.completeError(e);
|
||||
await onSkip(request);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<MultipleChoicePracticeActivityModel> _fetchActivity(
|
||||
MessageActivityRequest req,
|
||||
Future Function(MultipleChoicePracticeActivityModel) onFetch,
|
||||
) async {
|
||||
final result = await PracticeRepo.getPracticeActivity(req, messageInfo: {});
|
||||
|
||||
if (result.isError ||
|
||||
result.result is! MultipleChoicePracticeActivityModel) {
|
||||
throw Exception();
|
||||
}
|
||||
|
||||
final activityModel = result.result as MultipleChoicePracticeActivityModel;
|
||||
await onFetch(activityModel);
|
||||
return activityModel;
|
||||
}
|
||||
|
||||
Future<MultipleChoicePracticeActivityModel?> getNextActivity(
|
||||
Future Function(MessageActivityRequest) onSkip,
|
||||
Future Function(MultipleChoicePracticeActivityModel) onFetch,
|
||||
) async {
|
||||
if (session == null) {
|
||||
throw Exception("Called getNextActivity without loading session");
|
||||
}
|
||||
|
||||
if (!session!.isComplete && _queue.isEmpty) {
|
||||
return _initActivityData(onSkip, onFetch);
|
||||
}
|
||||
|
||||
while (_queue.isNotEmpty) {
|
||||
final nextActivityCompleter = _queue.removeFirst();
|
||||
|
||||
try {
|
||||
final activity = await nextActivityCompleter.completer.future;
|
||||
return activity;
|
||||
} catch (e) {
|
||||
// Completer failed, skip to next
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
||||
|
|
@ -113,6 +115,7 @@ class AnalyticsActivityTarget {
|
|||
|
||||
class AnalyticsPracticeSessionModel {
|
||||
final DateTime startedAt;
|
||||
final ConstructTypeEnum type;
|
||||
final List<AnalyticsActivityTarget> practiceTargets;
|
||||
final String userL1;
|
||||
final String userL2;
|
||||
|
|
@ -121,6 +124,7 @@ class AnalyticsPracticeSessionModel {
|
|||
|
||||
AnalyticsPracticeSessionModel({
|
||||
required this.startedAt,
|
||||
required this.type,
|
||||
required this.practiceTargets,
|
||||
required this.userL1,
|
||||
required this.userL2,
|
||||
|
|
@ -173,6 +177,35 @@ class AnalyticsPracticeSessionModel {
|
|||
}).toList();
|
||||
}
|
||||
|
||||
/// Returns congratulations message based on performance
|
||||
String getCompletionMessage(BuildContext context) {
|
||||
final hasTimeBonus =
|
||||
state.elapsedSeconds <= AnalyticsPracticeConstants.timeForBonus;
|
||||
final hintsUsed = state.hintsUsed;
|
||||
|
||||
final bool perfectAccuracy = state.accuracy == 100;
|
||||
final bool noHintsUsed = hintsUsed == 0;
|
||||
final bool hintsAvailable = type == ConstructTypeEnum.morph;
|
||||
|
||||
//check how many conditions for bonuses the user met and return message accordingly
|
||||
final conditionsMet = [
|
||||
perfectAccuracy,
|
||||
!hintsAvailable || noHintsUsed,
|
||||
hasTimeBonus,
|
||||
].where((c) => c).length;
|
||||
|
||||
if (conditionsMet == 3) {
|
||||
return L10n.of(context).perfectPractice;
|
||||
}
|
||||
if (conditionsMet >= 2) {
|
||||
return L10n.of(context).greatPractice;
|
||||
}
|
||||
if (hintsAvailable && noHintsUsed) {
|
||||
return L10n.of(context).usedNoHints;
|
||||
}
|
||||
return L10n.of(context).youveCompletedPractice;
|
||||
}
|
||||
|
||||
void setElapsedSeconds(int seconds) =>
|
||||
state = state.copyWith(elapsedSeconds: seconds);
|
||||
|
||||
|
|
@ -187,9 +220,15 @@ class AnalyticsPracticeSessionModel {
|
|||
void submitAnswer(List<OneConstructUse> uses) =>
|
||||
state = state.copyWith(completedUses: [...state.completedUses, ...uses]);
|
||||
|
||||
void useHint() => state = state.copyWith(hintsUsed: state.hintsUsed + 1);
|
||||
|
||||
factory AnalyticsPracticeSessionModel.fromJson(Map<String, dynamic> json) {
|
||||
return AnalyticsPracticeSessionModel(
|
||||
startedAt: DateTime.parse(json['startedAt'] as String),
|
||||
type: ConstructTypeEnum.values.firstWhere(
|
||||
(e) => e.name == json['type'] as String,
|
||||
orElse: () => ConstructTypeEnum.vocab,
|
||||
),
|
||||
practiceTargets: (json['practiceTargets'] as List<dynamic>)
|
||||
.map((e) => AnalyticsActivityTarget.fromJson(e))
|
||||
.whereType<AnalyticsActivityTarget>()
|
||||
|
|
@ -203,6 +242,7 @@ class AnalyticsPracticeSessionModel {
|
|||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'startedAt': startedAt.toIso8601String(),
|
||||
'type': type.name,
|
||||
'practiceTargets': practiceTargets.map((e) => e.toJson()).toList(),
|
||||
'userL1': userL1,
|
||||
'userL2': userL2,
|
||||
|
|
@ -215,6 +255,8 @@ class AnalyticsPracticeSessionState {
|
|||
final List<OneConstructUse> completedUses;
|
||||
final int currentIndex;
|
||||
final bool finished;
|
||||
|
||||
final int hintsUsed;
|
||||
final int elapsedSeconds;
|
||||
final int skippedActivities;
|
||||
|
||||
|
|
@ -222,6 +264,7 @@ class AnalyticsPracticeSessionState {
|
|||
this.completedUses = const [],
|
||||
this.currentIndex = 0,
|
||||
this.finished = false,
|
||||
this.hintsUsed = 0,
|
||||
this.elapsedSeconds = 0,
|
||||
this.skippedActivities = 0,
|
||||
});
|
||||
|
|
@ -275,6 +318,7 @@ class AnalyticsPracticeSessionState {
|
|||
List<OneConstructUse>? completedUses,
|
||||
int? currentIndex,
|
||||
bool? finished,
|
||||
int? hintsUsed,
|
||||
int? elapsedSeconds,
|
||||
int? skippedActivities,
|
||||
}) {
|
||||
|
|
@ -282,6 +326,7 @@ class AnalyticsPracticeSessionState {
|
|||
completedUses: completedUses ?? this.completedUses,
|
||||
currentIndex: currentIndex ?? this.currentIndex,
|
||||
finished: finished ?? this.finished,
|
||||
hintsUsed: hintsUsed ?? this.hintsUsed,
|
||||
elapsedSeconds: elapsedSeconds ?? this.elapsedSeconds,
|
||||
skippedActivities: skippedActivities ?? this.skippedActivities,
|
||||
);
|
||||
|
|
@ -292,6 +337,7 @@ class AnalyticsPracticeSessionState {
|
|||
'completedUses': completedUses.map((e) => e.toJson()).toList(),
|
||||
'currentIndex': currentIndex,
|
||||
'finished': finished,
|
||||
'hintsUsed': hintsUsed,
|
||||
'elapsedSeconds': elapsedSeconds,
|
||||
'skippedActivities': skippedActivities,
|
||||
};
|
||||
|
|
@ -307,6 +353,7 @@ class AnalyticsPracticeSessionState {
|
|||
[],
|
||||
currentIndex: json['currentIndex'] as int? ?? 0,
|
||||
finished: json['finished'] as bool? ?? false,
|
||||
hintsUsed: json['hintsUsed'] as int? ?? 0,
|
||||
elapsedSeconds: json['elapsedSeconds'] as int? ?? 0,
|
||||
skippedActivities: json['skippedActivities'] as int? ?? 0,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ class AnalyticsPracticeSessionRepo {
|
|||
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
|
||||
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
|
||||
startedAt: DateTime.now(),
|
||||
type: type,
|
||||
practiceTargets: targets,
|
||||
);
|
||||
return session;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/text_to_speech/tts_controller.dart';
|
||||
|
||||
class AnalyticsPracticeUiController {
|
||||
static String getChoiceTargetId(String choiceId, ConstructTypeEnum type) =>
|
||||
'${type.name}-choice-card-${choiceId.replaceAll(' ', '_')}';
|
||||
|
||||
static void playTargetAudio(
|
||||
MultipleChoicePracticeActivityModel activity,
|
||||
ConstructTypeEnum type,
|
||||
String language,
|
||||
) {
|
||||
if (activity is! VocabMeaningPracticeActivityModel) return;
|
||||
|
||||
final token = activity.tokens.first;
|
||||
TtsController.tryToSpeak(
|
||||
token.vocabConstructID.lemma,
|
||||
langCode: language,
|
||||
pos: token.pos,
|
||||
morph: token.morph.map((k, v) => MapEntry(k.name, v)),
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,79 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
|
||||
class AudioContinueButton extends StatelessWidget {
|
||||
final VocabAudioPracticeActivityModel activity;
|
||||
|
||||
final bool activityComplete;
|
||||
final int correctAnswers;
|
||||
|
||||
final VoidCallback onContinue;
|
||||
|
||||
const AudioContinueButton({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.onContinue,
|
||||
required this.activityComplete,
|
||||
required this.correctAnswers,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final activity = this.activity;
|
||||
|
||||
final totalAnswers = activity.multipleChoiceContent.answers.length;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
spacing: 8.0,
|
||||
children: [
|
||||
// Progress ovals row
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(
|
||||
totalAnswers,
|
||||
(index) => Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 4),
|
||||
child: Container(
|
||||
height: 16.0,
|
||||
decoration: BoxDecoration(
|
||||
color: index < correctAnswers
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceContainerHighest
|
||||
.withValues(alpha: 0.7),
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Continue button
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: activityComplete ? onContinue : null,
|
||||
child: Text(
|
||||
L10n.of(context).continueText,
|
||||
style: const TextStyle(fontSize: 16.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ class AudioChoiceCard extends StatelessWidget {
|
|||
final VoidCallback onPressed;
|
||||
final bool isCorrect;
|
||||
final bool isEnabled;
|
||||
final bool showPhoneticTranscription;
|
||||
final bool showHint;
|
||||
|
||||
const AudioChoiceCard({
|
||||
required this.choiceId,
|
||||
|
|
@ -23,7 +23,7 @@ class AudioChoiceCard extends StatelessWidget {
|
|||
required this.onPressed,
|
||||
required this.isCorrect,
|
||||
this.isEnabled = true,
|
||||
this.showPhoneticTranscription = false,
|
||||
this.showHint = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ class AudioChoiceCard extends StatelessWidget {
|
|||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showPhoneticTranscription)
|
||||
if (showHint)
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import 'package:fluffychat/config/app_config.dart';
|
|||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/percent_marker_bar.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/stat_card.dart';
|
||||
|
|
@ -16,10 +15,13 @@ import 'package:fluffychat/widgets/matrix.dart';
|
|||
|
||||
class CompletedActivitySessionView extends StatelessWidget {
|
||||
final AnalyticsPracticeSessionModel session;
|
||||
final AnalyticsPracticeState controller;
|
||||
const CompletedActivitySessionView(
|
||||
this.session,
|
||||
this.controller, {
|
||||
final VoidCallback launchSession;
|
||||
final Future<double> levelProgress;
|
||||
|
||||
const CompletedActivitySessionView({
|
||||
required this.session,
|
||||
required this.launchSession,
|
||||
required this.levelProgress,
|
||||
super.key,
|
||||
});
|
||||
|
||||
|
|
@ -47,7 +49,7 @@ class CompletedActivitySessionView extends StatelessWidget {
|
|||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
controller.getCompletionMessage(context),
|
||||
session.getCompletionMessage(context),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
|
||||
|
|
@ -81,12 +83,10 @@ class CompletedActivitySessionView extends StatelessWidget {
|
|||
bottom: 16.0,
|
||||
),
|
||||
child: FutureBuilder(
|
||||
future: controller.derivedAnalyticsData,
|
||||
future: levelProgress,
|
||||
builder: (context, snapshot) => AnimatedProgressBar(
|
||||
height: 20.0,
|
||||
widthPercent: snapshot.hasData
|
||||
? snapshot.data!.levelProgress
|
||||
: 0.0,
|
||||
widthPercent: snapshot.data ?? 0.0,
|
||||
backgroundColor: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
|
|
@ -144,7 +144,7 @@ class CompletedActivitySessionView extends StatelessWidget {
|
|||
vertical: 8.0,
|
||||
),
|
||||
),
|
||||
onPressed: () => controller.reloadSession(),
|
||||
onPressed: launchSession,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Text(L10n.of(context).anotherRound)],
|
||||
|
|
|
|||
138
lib/pangea/analytics_practice/grammar_error_example_widget.dart
Normal file
138
lib/pangea/analytics_practice/grammar_error_example_widget.dart
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/config/setting_keys.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
|
||||
class GrammarErrorExampleWidget extends StatelessWidget {
|
||||
final GrammarErrorPracticeActivityModel activity;
|
||||
final bool showTranslation;
|
||||
|
||||
const GrammarErrorExampleWidget({
|
||||
super.key,
|
||||
required this.activity,
|
||||
required this.showTranslation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = activity.text;
|
||||
final errorOffset = activity.errorOffset;
|
||||
final errorLength = activity.errorLength;
|
||||
|
||||
const maxContextChars = 50;
|
||||
|
||||
final chars = text.characters;
|
||||
final totalLength = chars.length;
|
||||
|
||||
// ---------- BEFORE ----------
|
||||
int beforeStart = 0;
|
||||
bool trimmedBefore = false;
|
||||
|
||||
if (errorOffset > maxContextChars) {
|
||||
int desiredStart = errorOffset - maxContextChars;
|
||||
|
||||
// Snap left to nearest whitespace to avoid cutting words
|
||||
while (desiredStart > 0 && chars.elementAt(desiredStart) != ' ') {
|
||||
desiredStart--;
|
||||
}
|
||||
|
||||
beforeStart = desiredStart;
|
||||
trimmedBefore = true;
|
||||
}
|
||||
|
||||
final before = chars
|
||||
.skip(beforeStart)
|
||||
.take(errorOffset - beforeStart)
|
||||
.toString();
|
||||
|
||||
// ---------- AFTER ----------
|
||||
int afterEnd = totalLength;
|
||||
bool trimmedAfter = false;
|
||||
|
||||
final errorEnd = errorOffset + errorLength;
|
||||
final afterChars = totalLength - errorEnd;
|
||||
|
||||
if (afterChars > maxContextChars) {
|
||||
int desiredEnd = errorEnd + maxContextChars;
|
||||
|
||||
// Snap right to nearest whitespace
|
||||
while (desiredEnd < totalLength && chars.elementAt(desiredEnd) != ' ') {
|
||||
desiredEnd++;
|
||||
}
|
||||
|
||||
afterEnd = desiredEnd;
|
||||
trimmedAfter = true;
|
||||
}
|
||||
|
||||
final after = chars.skip(errorEnd).take(afterEnd - errorEnd).toString();
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Color.alphaBlend(
|
||||
Colors.white.withAlpha(180),
|
||||
ThemeData.dark().colorScheme.primary,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryFixed,
|
||||
fontSize:
|
||||
AppSettings.fontSizeFactor.value *
|
||||
AppConfig.messageFontSize,
|
||||
),
|
||||
children: [
|
||||
if (trimmedBefore) const TextSpan(text: '…'),
|
||||
if (before.isNotEmpty) TextSpan(text: before),
|
||||
WidgetSpan(
|
||||
child: Container(
|
||||
height: 4.0,
|
||||
width: (errorLength * 8).toDouble(),
|
||||
padding: const EdgeInsets.only(bottom: 2.0),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (after.isNotEmpty) TextSpan(text: after),
|
||||
if (trimmedAfter) const TextSpan(text: '…'),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
alignment: Alignment.topCenter,
|
||||
child: showTranslation
|
||||
? Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
activity.translation,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.onPrimaryFixed,
|
||||
fontSize:
|
||||
AppSettings.fontSizeFactor.value *
|
||||
AppConfig.messageFontSize,
|
||||
fontStyle: FontStyle.italic,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: 3,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
222
lib/pangea/analytics_practice/ongoing_activity_session_view.dart
Normal file
222
lib/pangea/analytics_practice/ongoing_activity_session_view.dart
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/activity_choices_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/activity_content_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/activity_feedback_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/activity_hint_section_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/activity_hints_progress_widget.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/audio_activity_continue_button_widget.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/async_state.dart';
|
||||
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
|
||||
import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
|
||||
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.dart';
|
||||
import 'package:fluffychat/pangea/phonetic_transcription/phonetic_transcription_widget.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class OngoingActivitySessionView extends StatelessWidget {
|
||||
final AnalyticsPracticeState controller;
|
||||
|
||||
const OngoingActivitySessionView(this.controller, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isColumnMode = FluffyThemes.isColumnMode(context);
|
||||
TextStyle? titleStyle = isColumnMode
|
||||
? Theme.of(context).textTheme.titleLarge
|
||||
: Theme.of(context).textTheme.titleMedium;
|
||||
titleStyle = titleStyle?.copyWith(fontWeight: FontWeight.bold);
|
||||
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: controller.activityState,
|
||||
builder: (context, state, _) {
|
||||
final activity = controller.activity;
|
||||
return Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
//Hints counter bar for grammar activities only
|
||||
if (controller.widget.type == ConstructTypeEnum.morph)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: ActivityHintsProgress(
|
||||
hintsUsed: controller.session.hintsUsed,
|
||||
),
|
||||
),
|
||||
//per-activity instructions, add switch statement once there are more types
|
||||
const InstructionsInlineTooltip(
|
||||
instructionsEnum: InstructionsEnum.selectMeaning,
|
||||
padding: EdgeInsets.symmetric(vertical: 8.0),
|
||||
),
|
||||
SizedBox(
|
||||
height: 75.0,
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
if (activity == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final isAudioActivity =
|
||||
activity.activityType ==
|
||||
ActivityTypeEnum.lemmaAudio;
|
||||
final isVocabType =
|
||||
controller.widget.type == ConstructTypeEnum.vocab;
|
||||
|
||||
final token = activity.tokens.first;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Text(
|
||||
isAudioActivity && isVocabType
|
||||
? L10n.of(context).selectAllWords
|
||||
: activity.practiceTarget.promptText(context),
|
||||
textAlign: TextAlign.center,
|
||||
style: titleStyle,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (isVocabType && !isAudioActivity)
|
||||
PhoneticTranscriptionWidget(
|
||||
text: token.vocabConstructID.lemma,
|
||||
pos: token.pos,
|
||||
morph: token.morph.map(
|
||||
(k, v) => MapEntry(k.name, v),
|
||||
),
|
||||
textLanguage: MatrixState
|
||||
.pangeaController
|
||||
.userController
|
||||
.userL2!,
|
||||
style: const TextStyle(fontSize: 14.0),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
ListenableBuilder(
|
||||
listenable: controller.notifier,
|
||||
builder: (context, _) {
|
||||
final selectedMorphChoice = controller.notifier
|
||||
.selectedMorphChoice(activity);
|
||||
return Column(
|
||||
children: [
|
||||
const SizedBox(height: 16.0),
|
||||
if (activity != null)
|
||||
Center(
|
||||
child: ActivityContent(
|
||||
activity: activity,
|
||||
showHint: controller.notifier.showHint,
|
||||
exampleMessage: controller.exampleMessage,
|
||||
audioFile: controller.data.getAudioFile(
|
||||
activity,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
if (activity != null)
|
||||
ActivityHintSection(
|
||||
activity: activity,
|
||||
onPressed: controller.onHintPressed,
|
||||
hintPressed: controller.notifier.showHint,
|
||||
enabled: controller.notifier.enableHintPress(
|
||||
activity,
|
||||
controller.session.hintsUsed,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
switch (state) {
|
||||
AsyncError(error: final error) => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
//allow try to reload activity in case of error
|
||||
ErrorIndicator(
|
||||
message: error.toLocalizedString(context),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextButton.icon(
|
||||
onPressed: controller.startSession,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: Text(L10n.of(context).tryAgain),
|
||||
),
|
||||
],
|
||||
),
|
||||
AsyncLoaded(value: final activity) => Builder(
|
||||
builder: (context) {
|
||||
List<InlineSpan>? audioExampleMessage;
|
||||
String? audioTranslation;
|
||||
|
||||
if (activity
|
||||
is VocabAudioPracticeActivityModel) {
|
||||
audioExampleMessage =
|
||||
activity.exampleMessage.exampleMessage;
|
||||
audioTranslation = controller.data
|
||||
.getAudioTranslation(activity);
|
||||
}
|
||||
|
||||
return ActivityChoices(
|
||||
activity: activity,
|
||||
choices: controller.data.filteredChoices(
|
||||
activity,
|
||||
controller.widget.type,
|
||||
),
|
||||
type: controller.widget.type,
|
||||
isComplete: controller.notifier
|
||||
.activityComplete(activity),
|
||||
showHint: controller.notifier.showHint,
|
||||
onSelectChoice: controller.onSelectChoice,
|
||||
audioExampleMessage: audioExampleMessage,
|
||||
audioTranslation: audioTranslation,
|
||||
);
|
||||
},
|
||||
),
|
||||
_ => Center(
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator.adaptive(),
|
||||
),
|
||||
),
|
||||
},
|
||||
const SizedBox(height: 16.0),
|
||||
if (activity != null && selectedMorphChoice != null)
|
||||
ActivityFeedback(
|
||||
activity: activity,
|
||||
selectedChoice: selectedMorphChoice,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (activity is VocabAudioPracticeActivityModel)
|
||||
ListenableBuilder(
|
||||
listenable: controller.notifier,
|
||||
builder: (context, _) => Container(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: AudioContinueButton(
|
||||
activity: activity,
|
||||
onContinue: controller.startNextActivity,
|
||||
activityComplete: controller.notifier.activityComplete(
|
||||
activity,
|
||||
),
|
||||
correctAnswers: controller.notifier.correctAnswersSelected(
|
||||
activity,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,7 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_model.dart';
|
||||
import 'package:fluffychat/pangea/choreographer/choreo_record_model.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
|
||||
|
||||
|
|
@ -97,20 +92,6 @@ class MessageActivityRequest {
|
|||
}
|
||||
}
|
||||
|
||||
String promptText(BuildContext context) {
|
||||
switch (target.activityType) {
|
||||
case ActivityTypeEnum.grammarCategory:
|
||||
return L10n.of(context).whatIsTheMorphTag(
|
||||
target.morphFeature!.getDisplayCopy(context),
|
||||
target.tokens.first.text.content,
|
||||
);
|
||||
case ActivityTypeEnum.grammarError:
|
||||
return L10n.of(context).fillInBlank;
|
||||
default:
|
||||
return target.tokens.first.vocabConstructID.lemma;
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'user_l1': userL1,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
|
||||
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
|
||||
|
|
@ -34,6 +36,20 @@ class PracticeTarget {
|
|||
}
|
||||
}
|
||||
|
||||
String promptText(BuildContext context) {
|
||||
switch (activityType) {
|
||||
case ActivityTypeEnum.grammarCategory:
|
||||
return L10n.of(context).whatIsTheMorphTag(
|
||||
morphFeature!.getDisplayCopy(context),
|
||||
tokens.first.text.content,
|
||||
);
|
||||
case ActivityTypeEnum.grammarError:
|
||||
return L10n.of(context).fillInBlank;
|
||||
default:
|
||||
return tokens.first.vocabConstructID.lemma;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue