feat: grammar practice

This commit is contained in:
ggurdin 2026-01-14 16:06:22 -05:00
parent 3be47ab6b0
commit b698e2e84f
No known key found for this signature in database
GPG key ID: A01CB41737CBB478
28 changed files with 573 additions and 303 deletions

View file

@ -38,6 +38,7 @@ import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.dart'
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_page/activity_archive.dart';
import 'package:fluffychat/pangea/analytics_page/empty_analytics_page.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_page.dart';
import 'package:fluffychat/pangea/analytics_summary/level_analytics_details_content.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/chat_settings/pages/edit_course.dart';
@ -59,7 +60,6 @@ import 'package:fluffychat/pangea/login/pages/signup.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics.dart';
import 'package:fluffychat/pangea/spaces/space_constants.dart';
import 'package:fluffychat/pangea/subscription/pages/settings_subscription.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
import 'package:fluffychat/widgets/config_viewer.dart';
import 'package:fluffychat/widgets/layouts/empty_page.dart';
import 'package:fluffychat/widgets/layouts/two_column_layout.dart';
@ -542,6 +542,18 @@ abstract class AppRoutes {
),
redirect: loggedOutRedirect,
routes: [
GoRoute(
path: 'practice',
pageBuilder: (context, state) {
return defaultPageBuilder(
context,
state,
const AnalyticsPractice(
type: ConstructTypeEnum.morph,
),
);
},
),
GoRoute(
path: ':construct',
pageBuilder: (context, state) {
@ -580,7 +592,9 @@ abstract class AppRoutes {
return defaultPageBuilder(
context,
state,
const VocabPractice(),
const AnalyticsPractice(
type: ConstructTypeEnum.vocab,
),
);
},
),

View file

@ -5046,5 +5046,9 @@
"voice": "Voice",
"youLeftTheChat": "🚪 You left the chat",
"downloadInitiated": "Download initiated",
"webDownloadPermissionMessage": "If your browser blocks downloads, please enable downloads for this site."
"webDownloadPermissionMessage": "If your browser blocks downloads, please enable downloads for this site.",
"practiceGrammar": "Practice grammar",
"notEnoughToPractice": "Send more messages to unlock practice",
"constructUseCorGCDesc": "Correct grammar category practice",
"constructUseIncGCDesc": "Incorrect grammar category practice"
}

View file

@ -183,72 +183,66 @@ class ConstructAnalyticsViewState extends State<ConstructAnalyticsView> {
),
),
floatingActionButton:
widget.view == ConstructTypeEnum.vocab && widget.construct == null
? _buildVocabPracticeButton(context)
: null,
widget.construct == null ? _PracticeButton(view: widget.view) : null,
);
}
}
Widget _buildVocabPracticeButton(BuildContext context) {
// Check if analytics is loaded first
if (MatrixState
.pangeaController.matrixState.analyticsDataService.isInitializing) {
class _PracticeButton extends StatelessWidget {
final ConstructTypeEnum view;
const _PracticeButton({required this.view});
void _showSnackbar(BuildContext context, String message) {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
behavior: SnackBarBehavior.floating,
),
);
}
@override
Widget build(BuildContext context) {
final analyticsService = Matrix.of(context).analyticsDataService;
if (analyticsService.isInitializing) {
return FloatingActionButton.extended(
onPressed: () => _showSnackbar(
context,
L10n.of(context).loadingPleaseWait,
),
label: Text(view.practiceButtonText(context)),
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
);
}
final count = analyticsService.numConstructs(view);
final enabled = count >= 10;
return FloatingActionButton.extended(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Loading vocabulary data...',
),
behavior: SnackBarBehavior.floating,
),
);
},
label: Text(L10n.of(context).practiceVocab),
backgroundColor: Theme.of(context).colorScheme.surface,
foregroundColor:
Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
onPressed: enabled
? () => context.go("/rooms/analytics/${view.name}/practice")
: () => _showSnackbar(
context,
L10n.of(context).notEnoughToPractice,
),
backgroundColor:
enabled ? null : Theme.of(context).colorScheme.surfaceContainer,
foregroundColor: enabled
? null
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!enabled) ...[
const Icon(Icons.lock_outline, size: 18),
const SizedBox(width: 4),
],
Text(view.practiceButtonText(context)),
],
),
);
}
final vocabCount = MatrixState
.pangeaController.matrixState.analyticsDataService
.numConstructs(ConstructTypeEnum.vocab);
final hasEnoughVocab = vocabCount >= 10;
return FloatingActionButton.extended(
onPressed: hasEnoughVocab
? () {
context.go(
"/rooms/analytics/${ConstructTypeEnum.vocab.name}/practice",
);
}
: () {
ScaffoldMessenger.of(context).hideCurrentSnackBar();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).mustHave10Words,
),
behavior: SnackBarBehavior.floating,
),
);
},
backgroundColor:
hasEnoughVocab ? null : Theme.of(context).colorScheme.surfaceContainer,
foregroundColor: hasEnoughVocab
? null
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.5),
label: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!hasEnoughVocab) ...[
const Icon(Icons.lock_outline, size: 18),
const SizedBox(width: 4),
],
Text(L10n.of(context).practiceVocab),
],
),
);
}

View file

@ -75,6 +75,7 @@ class MorphAnalyticsListView extends StatelessWidget {
childCount: controller.features.length,
),
),
const SliverToBoxAdapter(child: SizedBox(height: 75.0)),
],
),
),

View file

@ -4,20 +4,15 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
import 'package:fluffychat/pangea/morphs/parts_of_speech_enum.dart';
enum ConstructTypeEnum {
/// for vocabulary words
vocab,
/// for morphs, actually called "Grammar" in the UI... :P
morph,
}
morph;
extension ConstructExtension on ConstructTypeEnum {
String get string {
switch (this) {
case ConstructTypeEnum.vocab:
@ -37,25 +32,6 @@ extension ConstructExtension on ConstructTypeEnum {
}
}
int get maxXPPerLemma {
switch (this) {
case ConstructTypeEnum.vocab:
return AnalyticsConstants.vocabUseMaxXP;
case ConstructTypeEnum.morph:
return AnalyticsConstants.morphUseMaxXP;
}
}
String? getDisplayCopy(String category, BuildContext context) {
switch (this) {
case ConstructTypeEnum.morph:
return MorphFeaturesEnumExtension.fromString(category)
.getDisplayCopy(context);
case ConstructTypeEnum.vocab:
return getVocabCategoryName(category, context);
}
}
ProgressIndicatorEnum get indicator {
switch (this) {
case ConstructTypeEnum.morph:
@ -64,9 +40,17 @@ extension ConstructExtension on ConstructTypeEnum {
return ProgressIndicatorEnum.wordsUsed;
}
}
}
class ConstructTypeUtil {
String practiceButtonText(BuildContext context) {
final l10n = L10n.of(context);
switch (this) {
case ConstructTypeEnum.vocab:
return l10n.practiceVocab;
case ConstructTypeEnum.morph:
return l10n.practiceGrammar;
}
}
static ConstructTypeEnum fromString(String? string) {
switch (string) {
case 'v':

View file

@ -82,6 +82,10 @@ enum ConstructUseTypeEnum {
// vocab lemma audio activity
corLA,
incLA,
// grammar category activity
corGC,
incGC,
}
extension ConstructUseTypeExtension on ConstructUseTypeEnum {
@ -163,6 +167,10 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
return L10n.of(context).constructUseCorLADesc;
case ConstructUseTypeEnum.incLA:
return L10n.of(context).constructUseIncLADesc;
case ConstructUseTypeEnum.corGC:
return L10n.of(context).constructUseCorGCDesc;
case ConstructUseTypeEnum.incGC:
return L10n.of(context).constructUseIncGCDesc;
}
}
@ -203,6 +211,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corM:
case ConstructUseTypeEnum.incM:
case ConstructUseTypeEnum.ignM:
case ConstructUseTypeEnum.corGC:
case ConstructUseTypeEnum.incGC:
return ActivityTypeEnum.morphId.icon;
case ConstructUseTypeEnum.em:
return ActivityTypeEnum.emoji.icon;
@ -235,6 +245,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corM:
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.corLA:
case ConstructUseTypeEnum.corGC:
return 5;
case ConstructUseTypeEnum.pvm:
@ -275,6 +286,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incM:
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.incLA:
case ConstructUseTypeEnum.incGC:
return -1;
case ConstructUseTypeEnum.incPA:
@ -326,6 +338,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corLA:
case ConstructUseTypeEnum.incLA:
case ConstructUseTypeEnum.bonus:
case ConstructUseTypeEnum.corGC:
case ConstructUseTypeEnum.incGC:
return false;
}
}
@ -369,6 +383,8 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.click:
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.corGC:
case ConstructUseTypeEnum.incGC:
return LearningSkillsEnum.reading;
case ConstructUseTypeEnum.pvm:
return LearningSkillsEnum.speaking;
@ -398,6 +414,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.corMM:
case ConstructUseTypeEnum.corLM:
case ConstructUseTypeEnum.corLA:
case ConstructUseTypeEnum.corGC:
return SpaceAnalyticsSummaryEnum.numChoicesCorrect;
case ConstructUseTypeEnum.incIt:
@ -410,6 +427,7 @@ extension ConstructUseTypeExtension on ConstructUseTypeEnum {
case ConstructUseTypeEnum.incMM:
case ConstructUseTypeEnum.incLM:
case ConstructUseTypeEnum.incLA:
case ConstructUseTypeEnum.incGC:
return SpaceAnalyticsSummaryEnum.numChoicesIncorrect;
case ConstructUseTypeEnum.ignIt:

View file

@ -117,7 +117,7 @@ class OneConstructUse {
debugger(when: kDebugMode && json['constructType'] == null);
final ConstructTypeEnum constructType = json['constructType'] != null
? ConstructTypeUtil.fromString(json['constructType'])
? ConstructTypeEnum.fromString(json['constructType'])
: ConstructTypeEnum.vocab;
final useType = ConstructUseTypeUtil.fromString(json['useType']);

View file

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_details_popup/vocab_analytics_list_tile.dart';
import 'package:fluffychat/pangea/analytics_misc/analytics_navigation_util.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/constructs/construct_identifier.dart';

View file

@ -1,4 +1,4 @@
class VocabPracticeConstants {
class AnalyticsPracticeConstants {
static const int timeForBonus = 60;
static const int practiceGroupSize = 10;
}

View file

@ -13,6 +13,9 @@ 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_session_model.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.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/lemmas/lemma_info_repo.dart';
@ -22,47 +25,52 @@ import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_m
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_generation_repo.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_repo.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_view.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabPracticeChoice {
class PracticeChoice {
final String choiceId;
final String choiceText;
final String? choiceEmoji;
const VocabPracticeChoice({
const PracticeChoice({
required this.choiceId,
required this.choiceText,
this.choiceEmoji,
});
}
class SessionLoader extends AsyncLoader<VocabPracticeSessionModel> {
@override
Future<VocabPracticeSessionModel> fetch() => VocabPracticeSessionRepo.get();
}
class VocabPractice extends StatefulWidget {
const VocabPractice({super.key});
class SessionLoader extends AsyncLoader<AnalyticsPracticeSessionModel> {
final ConstructTypeEnum type;
SessionLoader({required this.type});
@override
VocabPracticeState createState() => VocabPracticeState();
Future<AnalyticsPracticeSessionModel> fetch() =>
AnalyticsPracticeSessionRepo.get(type);
}
class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
final SessionLoader _sessionLoader = SessionLoader();
class AnalyticsPractice extends StatefulWidget {
final ConstructTypeEnum type;
const AnalyticsPractice({
super.key,
required this.type,
});
@override
AnalyticsPracticeState createState() => AnalyticsPracticeState();
}
class AnalyticsPracticeState extends State<AnalyticsPractice>
with AnalyticsUpdater {
late final SessionLoader _sessionLoader;
final ValueNotifier<AsyncState<PracticeActivityModel>> activityState =
ValueNotifier(const AsyncState.idle());
final Queue<MapEntry<ConstructIdentifier, Completer<PracticeActivityModel>>>
_queue = Queue();
final Queue<MapEntry<String, Completer<PracticeActivityModel>>> _queue =
Queue();
final ValueNotifier<ConstructIdentifier?> activityConstructId =
ValueNotifier<ConstructIdentifier?>(null);
final ValueNotifier<String?> activityText = ValueNotifier<String?>(null);
final ValueNotifier<double> progressNotifier = ValueNotifier<double>(0.0);
@ -74,6 +82,7 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
@override
void initState() {
super.initState();
_sessionLoader = SessionLoader(type: widget.type);
_startSession();
_languageStreamSubscription = MatrixState
.pangeaController.userController.languageStream.stream
@ -84,13 +93,13 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
void dispose() {
_languageStreamSubscription?.cancel();
if (_isComplete) {
VocabPracticeSessionRepo.clear();
AnalyticsPracticeSessionRepo.clear();
} else {
_saveSession();
}
_sessionLoader.dispose();
activityState.dispose();
activityConstructId.dispose();
activityText.dispose();
progressNotifier.dispose();
super.dispose();
}
@ -102,19 +111,19 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
bool get _isComplete => _sessionLoader.value?.isComplete ?? false;
ValueNotifier<AsyncState<VocabPracticeSessionModel>> get sessionState =>
ValueNotifier<AsyncState<AnalyticsPracticeSessionModel>> get sessionState =>
_sessionLoader.state;
AnalyticsDataService get _analyticsService =>
Matrix.of(context).analyticsDataService;
List<VocabPracticeChoice> filteredChoices(
List<PracticeChoice> filteredChoices(
PracticeTarget target,
MultipleChoiceActivity activity,
) {
final choices = activity.choices.toList();
final answer = activity.answers.first;
final filtered = <VocabPracticeChoice>[];
final filtered = <PracticeChoice>[];
final seenTexts = <String>{};
for (final id in choices) {
@ -129,7 +138,7 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
(choice) => choice.choiceText == text,
);
if (index != -1) {
filtered[index] = VocabPracticeChoice(
filtered[index] = PracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: getChoiceEmoji(target, id),
@ -140,7 +149,7 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
seenTexts.add(text);
filtered.add(
VocabPracticeChoice(
PracticeChoice(
choiceId: id,
choiceText: text,
choiceEmoji: getChoiceEmoji(target, id),
@ -164,11 +173,11 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
_choiceEmojis[target]?[choiceId];
String choiceTargetId(String choiceId) =>
'vocab-choice-card-${choiceId.replaceAll(' ', '_')}';
'${widget.type.name}-choice-card-${choiceId.replaceAll(' ', '_')}';
void _resetActivityState() {
activityState.value = const AsyncState.loading();
activityConstructId.value = null;
activityText.value = null;
}
void _resetSessionState() {
@ -187,7 +196,10 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
Future<void> _saveSession() async {
if (_sessionLoader.isLoaded) {
await VocabPracticeSessionRepo.update(_sessionLoader.value!);
await AnalyticsPracticeSessionRepo.update(
widget.type,
_sessionLoader.value!,
);
}
}
@ -225,7 +237,7 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
Future<void> reloadSession() async {
_resetActivityState();
_resetSessionState();
await VocabPracticeSessionRepo.clear();
await AnalyticsPracticeSessionRepo.clear();
_sessionLoader.reset();
await _startSession();
}
@ -253,7 +265,7 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
} else {
activityState.value = const AsyncState.loading();
final nextActivityCompleter = _queue.removeFirst();
activityConstructId.value = nextActivityCompleter.key;
activityText.value = nextActivityCompleter.key;
final activity = await nextActivityCompleter.value.future;
activityState.value = AsyncState.loaded(activity);
}
@ -277,7 +289,7 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
final res = await _fetchActivity(req);
if (!mounted) return;
activityConstructId.value = req.targetTokens.first.vocabConstructID;
activityText.value = req.activityText;
activityState.value = AsyncState.loaded(res);
} catch (e) {
if (!mounted) return;
@ -293,7 +305,7 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
final completer = Completer<PracticeActivityModel>();
_queue.add(
MapEntry(
request.targetTokens.first.vocabConstructID,
request.activityText,
completer,
),
);
@ -375,14 +387,14 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
final use = OneConstructUse(
useType: useType,
constructType: ConstructTypeEnum.vocab,
constructType: widget.type,
metadata: ConstructUseMetaData(
roomId: null,
timeStamp: DateTime.now(),
),
category: activity.targetTokens.first.pos,
lemma: activity.targetTokens.first.lemma.text,
form: activity.targetTokens.first.lemma.text,
category: activity.useCategory,
lemma: activity.useLemma,
form: activity.useForm,
xp: useType.pointValue,
);
@ -417,5 +429,5 @@ class VocabPracticeState extends State<VocabPractice> with AnalyticsUpdater {
_analyticsService.derivedData;
@override
Widget build(BuildContext context) => VocabPracticeView(this);
Widget build(BuildContext context) => AnalyticsPracticeView(this);
}

View file

@ -2,12 +2,11 @@ import 'dart:math';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/analytics_practice/analytics_practice_constants.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart';
class VocabPracticeSessionModel {
class AnalyticsPracticeSessionModel {
final DateTime startedAt;
final List<PracticeTarget> practiceTargets;
final String userL1;
@ -15,22 +14,16 @@ class VocabPracticeSessionModel {
VocabPracticeSessionState state;
VocabPracticeSessionModel({
AnalyticsPracticeSessionModel({
required this.startedAt,
required this.practiceTargets,
required this.userL1,
required this.userL2,
VocabPracticeSessionState? state,
}) : assert(
practiceTargets.every(
(t) => {ActivityTypeEnum.lemmaMeaning, ActivityTypeEnum.lemmaAudio}
.contains(t.activityType),
),
),
state = state ?? const VocabPracticeSessionState();
}) : state = state ?? const VocabPracticeSessionState();
int get _availableActivities => min(
VocabPracticeConstants.practiceGroupSize,
AnalyticsPracticeConstants.practiceGroupSize,
practiceTargets.length,
);
@ -47,7 +40,7 @@ class VocabPracticeSessionModel {
activityQualityFeedback: null,
targetTokens: target.tokens,
targetType: target.activityType,
targetMorphFeature: null,
targetMorphFeature: target.morphFeature,
);
}).toList();
}
@ -64,8 +57,8 @@ class VocabPracticeSessionModel {
completedUses: [...state.completedUses, use],
);
factory VocabPracticeSessionModel.fromJson(Map<String, dynamic> json) {
return VocabPracticeSessionModel(
factory AnalyticsPracticeSessionModel.fromJson(Map<String, dynamic> json) {
return AnalyticsPracticeSessionModel(
startedAt: DateTime.parse(json['startedAt'] as String),
practiceTargets: (json['practiceTargets'] as List<dynamic>)
.map((e) => PracticeTarget.fromJson(e))
@ -115,7 +108,7 @@ class VocabPracticeSessionState {
bool get _giveAccuracyBonus => accuracy >= 100.0;
bool get _giveTimeBonus =>
elapsedSeconds <= VocabPracticeConstants.timeForBonus;
elapsedSeconds <= AnalyticsPracticeConstants.timeForBonus;
int get bonusXP => accuracyBonusXP + timeBonusXP;

View file

@ -0,0 +1,182 @@
import 'dart:math';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.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/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_text_model.dart';
import 'package:fluffychat/pangea/lemmas/lemma.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_target.dart';
import 'package:fluffychat/widgets/matrix.dart';
class AnalyticsPracticeSessionRepo {
static final GetStorage _storage = GetStorage('practice_session');
static Future<AnalyticsPracticeSessionModel> get(
ConstructTypeEnum type,
) async {
final cached = _getCached(type);
if (cached != null) {
return cached;
}
final r = Random();
final activityTypes = ActivityTypeEnum.analyticsPracticeTypes(type);
final types = List.generate(
AnalyticsPracticeConstants.practiceGroupSize,
(_) => activityTypes[r.nextInt(activityTypes.length)],
);
final List<PracticeTarget> targets = [];
if (type == ConstructTypeEnum.vocab) {
final constructs = await _fetchVocab();
final targetCount = min(constructs.length, types.length);
targets.addAll([
for (var i = 0; i < targetCount; i++)
PracticeTarget(
tokens: [constructs[i].asToken],
activityType: types[i],
),
]);
} else {
final morphs = await _fetchMorphs();
targets.addAll([
for (final entry in morphs.entries)
PracticeTarget(
tokens: [entry.key],
activityType: types[targets.length],
morphFeature: entry.value,
),
]);
}
final session = AnalyticsPracticeSessionModel(
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
startedAt: DateTime.now(),
practiceTargets: targets,
);
await _setCached(type, session);
return session;
}
static Future<void> update(
ConstructTypeEnum type,
AnalyticsPracticeSessionModel session,
) =>
_setCached(type, session);
static Future<void> clear() => _storage.erase();
static Future<List<ConstructIdentifier>> _fetchVocab() async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab)
.then((map) => map.values.toList());
// sort by last used descending, nulls first
constructs.sort((a, b) {
final dateA = a.lastUsed;
final dateB = b.lastUsed;
if (dateA == null && dateB == null) return 0;
if (dateA == null) return -1;
if (dateB == null) return 1;
return dateA.compareTo(dateB);
});
return constructs
.where((construct) => construct.lemma.isNotEmpty)
.take(AnalyticsPracticeConstants.practiceGroupSize)
.map((construct) => construct.id)
.toList();
}
static Future<Map<PangeaToken, MorphFeaturesEnum>> _fetchMorphs() async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.morph)
.then((map) => map.values.toList());
// sort by last used descending, nulls first
constructs.sort((a, b) {
final dateA = a.lastUsed;
final dateB = b.lastUsed;
if (dateA == null && dateB == null) return 0;
if (dateA == null) return -1;
if (dateB == null) return 1;
return dateA.compareTo(dateB);
});
final targets = <PangeaToken, MorphFeaturesEnum>{};
final Set<String> seenForms = {};
for (final entry in constructs) {
if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) {
break;
}
final feature = MorphFeaturesEnumExtension.fromString(entry.id.category);
if (feature == MorphFeaturesEnum.Unknown) {
continue;
}
for (final use in entry.cappedUses) {
if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) {
break;
}
final form = use.form;
if (seenForms.contains(form) || form == null) {
continue;
}
seenForms.add(form);
final token = PangeaToken(
lemma: Lemma(
text: form,
saveVocab: true,
form: form,
),
text: PangeaTokenText.fromString(form),
pos: 'other',
morph: {feature: use.lemma},
);
targets[token] = feature;
break;
}
}
return targets;
}
static AnalyticsPracticeSessionModel? _getCached(
ConstructTypeEnum type,
) {
try {
final entry = _storage.read(type.name);
if (entry == null) return null;
final json = entry as Map<String, dynamic>;
return AnalyticsPracticeSessionModel.fromJson(json);
} catch (e) {
_storage.remove(type.name);
return null;
}
}
static Future<void> _setCached(
ConstructTypeEnum type,
AnalyticsPracticeSessionModel session,
) async {
await _storage.write(
type.name,
session.toJson(),
);
}
}

View file

@ -2,6 +2,13 @@ import 'package:flutter/material.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/l10n/l10n.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/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/meaning_choice_card.dart';
import 'package:fluffychat/pangea/analytics_practice/completed_activity_session_view.dart';
import 'package:fluffychat/pangea/analytics_practice/practice_timer_widget.dart';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/common/utils/async_state.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
@ -9,19 +16,12 @@ import 'package:fluffychat/pangea/instructions/instructions_enum.dart';
import 'package:fluffychat/pangea/instructions/instructions_inline_tooltip.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/vocab_practice/choice_cards/audio_choice_card.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/meaning_choice_card.dart';
import 'package:fluffychat/pangea/vocab_practice/completed_activity_session_view.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_timer_widget.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
class VocabPracticeView extends StatelessWidget {
final VocabPracticeState controller;
class AnalyticsPracticeView extends StatelessWidget {
final AnalyticsPracticeState controller;
const VocabPracticeView(this.controller, {super.key});
const AnalyticsPracticeView(this.controller, {super.key});
@override
Widget build(BuildContext context) {
@ -53,8 +53,8 @@ class VocabPracticeView extends StatelessWidget {
ValueListenableBuilder(
valueListenable: controller.sessionState,
builder: (context, state, __) {
if (state is AsyncLoaded<VocabPracticeSessionModel>) {
return VocabTimerWidget(
if (state is AsyncLoaded<AnalyticsPracticeSessionModel>) {
return PracticeTimerWidget(
key: ValueKey(state.value.startedAt),
initialSeconds: state.value.state.elapsedSeconds,
onTimeUpdate: controller.updateElapsedTime,
@ -78,12 +78,12 @@ class VocabPracticeView extends StatelessWidget {
valueListenable: controller.sessionState,
builder: (context, state, __) {
return switch (state) {
AsyncError<VocabPracticeSessionModel>(:final error) =>
AsyncError<AnalyticsPracticeSessionModel>(:final error) =>
ErrorIndicator(message: error.toString()),
AsyncLoaded<VocabPracticeSessionModel>(:final value) =>
AsyncLoaded<AnalyticsPracticeSessionModel>(:final value) =>
value.isComplete
? CompletedActivitySessionView(state.value, controller)
: _VocabActivityView(controller),
: _AnalyticsActivityView(controller),
_ => loading,
};
},
@ -93,10 +93,10 @@ class VocabPracticeView extends StatelessWidget {
}
}
class _VocabActivityView extends StatelessWidget {
final VocabPracticeState controller;
class _AnalyticsActivityView extends StatelessWidget {
final AnalyticsPracticeState controller;
const _VocabActivityView(
const _AnalyticsActivityView(
this.controller,
);
@ -119,10 +119,10 @@ class _VocabActivityView extends StatelessWidget {
Expanded(
flex: 1,
child: ValueListenableBuilder(
valueListenable: controller.activityConstructId,
builder: (context, constructId, __) => constructId != null
valueListenable: controller.activityText,
builder: (context, text, __) => text != null
? Text(
constructId.lemma,
text,
textAlign: TextAlign.center,
style:
Theme.of(context).textTheme.titleLarge?.copyWith(
@ -132,19 +132,19 @@ class _VocabActivityView extends StatelessWidget {
: const SizedBox(),
),
),
Expanded(
flex: 2,
child: Center(
child: ValueListenableBuilder(
valueListenable: controller.activityConstructId,
builder: (context, constructId, __) => constructId != null
? _ExampleMessageWidget(
controller.getExampleMessage(constructId),
)
: const SizedBox(),
),
),
),
// Expanded(
// flex: 2,
// child: Center(
// child: ValueListenableBuilder(
// valueListenable: controller.activityConstructId,
// builder: (context, constructId, __) => constructId != null
// ? _ExampleMessageWidget(
// controller.getExampleMessage(constructId),
// )
// : const SizedBox(),
// ),
// ),
// ),
Expanded(
flex: 6,
child: _ActivityChoicesWidget(controller),
@ -199,7 +199,7 @@ class _ExampleMessageWidget extends StatelessWidget {
}
class _ActivityChoicesWidget extends StatelessWidget {
final VocabPracticeState controller;
final AnalyticsPracticeState controller;
const _ActivityChoicesWidget(
this.controller,

View file

@ -1,8 +1,8 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/pangea/common/widgets/word_audio_button.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Displays an audio button with a select label in a row layout

View file

@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/pangea/analytics_practice/choice_cards/game_choice_card.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/vocab_practice/choice_cards/game_choice_card.dart';
/// Choice card for meaning activity with emoji, and alt text on flip
class MeaningChoiceCard extends StatelessWidget {

View file

@ -3,18 +3,18 @@ import 'package:flutter/material.dart';
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';
import 'package:fluffychat/pangea/analytics_summary/animated_progress_bar.dart';
import 'package:fluffychat/pangea/vocab_practice/percent_marker_bar.dart';
import 'package:fluffychat/pangea/vocab_practice/stat_card.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_page.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/matrix.dart';
class CompletedActivitySessionView extends StatelessWidget {
final VocabPracticeSessionModel session;
final VocabPracticeState controller;
final AnalyticsPracticeSessionModel session;
final AnalyticsPracticeState controller;
const CompletedActivitySessionView(
this.session,
this.controller, {
@ -198,7 +198,7 @@ class TimeStarsWidget extends StatelessWidget {
});
int get starCount {
const timeForBonus = VocabPracticeConstants.timeForBonus;
const timeForBonus = AnalyticsPracticeConstants.timeForBonus;
if (elapsedSeconds <= timeForBonus) return 5;
if (elapsedSeconds <= timeForBonus * 1.5) return 4;
if (elapsedSeconds <= timeForBonus * 2) return 3;

View file

@ -0,0 +1,65 @@
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/morphs/default_morph_mapping.dart';
import 'package:fluffychat/pangea/morphs/morph_models.dart';
import 'package:fluffychat/pangea/morphs/morph_repo.dart';
import 'package:fluffychat/pangea/practice_activities/message_activity_request.dart';
import 'package:fluffychat/pangea/practice_activities/multiple_choice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class MorphCategoryActivityGenerator {
static Future<MessageActivityResponse> get(
MessageActivityRequest req,
) async {
if (req.targetMorphFeature == null) {
throw ArgumentError(
"MorphCategoryActivityGenerator requires a targetMorphFeature",
);
}
final feature = req.targetMorphFeature!;
final morphTag = req.targetTokens.first.getMorphTag(feature);
if (morphTag == null) {
throw ArgumentError(
"Token does not have the specified morph feature",
);
}
MorphFeaturesAndTags morphs = defaultMorphMapping;
try {
final resp = await MorphsRepo.get();
morphs = resp;
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {"l2": MatrixState.pangeaController.userController.userL2},
);
}
final List<String> allTags = morphs.getDisplayTags(feature.name);
final List<String> possibleDistractors = allTags
.where(
(tag) => tag.toLowerCase() != morphTag.toLowerCase() && tag != "X",
)
.toList();
possibleDistractors.shuffle();
final choices = possibleDistractors.take(3).toSet();
choices.add(morphTag);
return MessageActivityResponse(
activity: PracticeActivityModel(
activityType: req.targetType,
targetTokens: [req.targetTokens.first],
langCode: req.userL2,
morphFeature: feature,
multipleChoiceContent: MultipleChoiceActivity(
choices: choices,
answers: {morphTag},
),
),
);
}
}

View file

@ -2,12 +2,12 @@ import 'dart:async';
import 'package:flutter/material.dart';
class VocabTimerWidget extends StatefulWidget {
class PracticeTimerWidget extends StatefulWidget {
final int initialSeconds;
final ValueChanged<int> onTimeUpdate;
final bool isRunning;
const VocabTimerWidget({
const PracticeTimerWidget({
required this.initialSeconds,
required this.onTimeUpdate,
this.isRunning = true,
@ -15,10 +15,10 @@ class VocabTimerWidget extends StatefulWidget {
});
@override
VocabTimerWidgetState createState() => VocabTimerWidgetState();
PracticeTimerWidgetState createState() => PracticeTimerWidgetState();
}
class VocabTimerWidgetState extends State<VocabTimerWidget> {
class PracticeTimerWidgetState extends State<PracticeTimerWidget> {
final Stopwatch _stopwatch = Stopwatch();
late int _initialSeconds;
Timer? _timer;
@ -33,7 +33,7 @@ class VocabTimerWidgetState extends State<VocabTimerWidget> {
}
@override
void didUpdateWidget(VocabTimerWidget oldWidget) {
void didUpdateWidget(PracticeTimerWidget oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.isRunning && !widget.isRunning) {
_stopTimer();

View file

@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
enum ActivityTypeEnum {
@ -13,7 +14,8 @@ enum ActivityTypeEnum {
morphId,
messageMeaning,
lemmaMeaning,
lemmaAudio;
lemmaAudio,
grammarCategory;
bool get includeTTSOnClick {
switch (this) {
@ -27,6 +29,7 @@ enum ActivityTypeEnum {
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.lemmaAudio:
case ActivityTypeEnum.lemmaMeaning:
case ActivityTypeEnum.grammarCategory:
return true;
}
}
@ -62,6 +65,9 @@ enum ActivityTypeEnum {
case 'lemma_audio':
case 'lemmaAudio':
return ActivityTypeEnum.lemmaAudio;
case 'grammar_category':
case 'grammarCategory':
return ActivityTypeEnum.grammarCategory;
default:
throw Exception('Unknown activity type: $split');
}
@ -117,6 +123,11 @@ enum ActivityTypeEnum {
ConstructUseTypeEnum.corLM,
ConstructUseTypeEnum.incLM,
];
case ActivityTypeEnum.grammarCategory:
return [
ConstructUseTypeEnum.corGC,
ConstructUseTypeEnum.incGC,
];
}
}
@ -140,6 +151,8 @@ enum ActivityTypeEnum {
return ConstructUseTypeEnum.corLA;
case ActivityTypeEnum.lemmaMeaning:
return ConstructUseTypeEnum.corLM;
case ActivityTypeEnum.grammarCategory:
return ConstructUseTypeEnum.corGC;
}
}
@ -163,6 +176,8 @@ enum ActivityTypeEnum {
return ConstructUseTypeEnum.incLA;
case ActivityTypeEnum.lemmaMeaning:
return ConstructUseTypeEnum.incLM;
case ActivityTypeEnum.grammarCategory:
return ConstructUseTypeEnum.incGC;
}
}
@ -182,6 +197,7 @@ enum ActivityTypeEnum {
case ActivityTypeEnum.morphId:
return Icons.format_shapes;
case ActivityTypeEnum.messageMeaning:
case ActivityTypeEnum.grammarCategory:
return Icons.star; // TODO: Add to L10n
}
}
@ -200,6 +216,7 @@ enum ActivityTypeEnum {
case ActivityTypeEnum.messageMeaning:
case ActivityTypeEnum.lemmaMeaning:
case ActivityTypeEnum.lemmaAudio:
case ActivityTypeEnum.grammarCategory:
return 1;
}
}
@ -210,4 +227,41 @@ enum ActivityTypeEnum {
ActivityTypeEnum.wordFocusListening,
ActivityTypeEnum.morphId,
];
static List<ActivityTypeEnum> get vocabPracticeTypes => [
ActivityTypeEnum.lemmaMeaning,
// ActivityTypeEnum.lemmaAudio,
];
static List<ActivityTypeEnum> get grammarPracticeTypes => [
ActivityTypeEnum.grammarCategory,
];
static List<ActivityTypeEnum> analyticsPracticeTypes(
ConstructTypeEnum constructType,
) {
switch (constructType) {
case ConstructTypeEnum.vocab:
return vocabPracticeTypes;
case ConstructTypeEnum.morph:
return grammarPracticeTypes;
}
}
ConstructTypeEnum get constructType {
switch (this) {
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.hiddenWordListening:
case ActivityTypeEnum.lemmaId:
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.messageMeaning:
case ActivityTypeEnum.lemmaMeaning:
case ActivityTypeEnum.lemmaAudio:
return ConstructTypeEnum.vocab;
case ActivityTypeEnum.morphId:
case ActivityTypeEnum.grammarCategory:
return ConstructTypeEnum.morph;
}
}
}

View file

@ -70,6 +70,15 @@ class MessageActivityRequest {
}
}
String get activityText {
switch (targetType) {
case ActivityTypeEnum.grammarCategory:
return "${targetTokens.first.vocabConstructID.lemma}: ${targetMorphFeature!.name}";
default:
return targetTokens.first.vocabConstructID.lemma;
}
}
Map<String, dynamic> toJson() {
return {
'user_l1': userL1,

View file

@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:collection/collection.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
@ -47,6 +48,45 @@ class PracticeActivityModel {
}
}
String get useCategory {
switch (activityType.constructType) {
case ConstructTypeEnum.morph:
assert(
morphFeature != null,
"morphFeature is null in PracticeActivityModel.useCategory",
);
return morphFeature!.name;
case ConstructTypeEnum.vocab:
return targetTokens.first.pos;
}
}
String get useLemma {
switch (activityType.constructType) {
case ConstructTypeEnum.morph:
assert(
morphFeature != null,
"morphFeature is null in PracticeActivityModel.useCategory",
);
final tag = targetTokens.first.getMorphTag(morphFeature!);
if (tag == null) {
throw ("tag is null in PracticeActivityModel.useLemma");
}
return tag;
case ConstructTypeEnum.vocab:
return targetTokens.first.lemma.text;
}
}
String get useForm {
switch (activityType.constructType) {
case ConstructTypeEnum.morph:
return targetTokens.first.lemma.form;
case ConstructTypeEnum.vocab:
return targetTokens.first.lemma.text;
}
}
PracticeTarget get practiceTarget => PracticeTarget(
tokens: targetTokens,
activityType: activityType,

View file

@ -9,6 +9,9 @@ import 'package:async/async.dart';
import 'package:get_storage/get_storage.dart';
import 'package:http/http.dart';
import 'package:fluffychat/pangea/analytics_practice/morph_category_activity_generator.dart';
import 'package:fluffychat/pangea/analytics_practice/vocab_audio_activity_generator.dart';
import 'package:fluffychat/pangea/analytics_practice/vocab_meaning_activity_generator.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/network/requests.dart';
import 'package:fluffychat/pangea/common/network/urls.dart';
@ -21,8 +24,6 @@ import 'package:fluffychat/pangea/practice_activities/message_activity_request.d
import 'package:fluffychat/pangea/practice_activities/morph_activity_generator.dart';
import 'package:fluffychat/pangea/practice_activities/practice_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/word_focus_listening_generator.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_audio_activity_generator.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_meaning_activity_generator.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// Represents an item in the completion cache.
@ -125,6 +126,8 @@ class PracticeRepo {
return VocabMeaningActivityGenerator.get(req);
case ActivityTypeEnum.lemmaAudio:
return VocabAudioActivityGenerator.get(req);
case ActivityTypeEnum.grammarCategory:
return MorphCategoryActivityGenerator.get(req);
case ActivityTypeEnum.morphId:
return MorphActivityGenerator.get(req);
case ActivityTypeEnum.wordMeaning:

View file

@ -1,102 +0,0 @@
import 'dart:math';
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/practice_activities/activity_type_enum.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_constants.dart';
import 'package:fluffychat/pangea/vocab_practice/vocab_practice_session_model.dart';
import 'package:fluffychat/widgets/matrix.dart';
class VocabPracticeSessionRepo {
static final GetStorage _storage = GetStorage('vocab_practice_session');
static Future<VocabPracticeSessionModel> get() async {
final cached = _getCached();
if (cached != null) {
return cached;
}
final r = Random();
final activityTypes = [
ActivityTypeEnum.lemmaMeaning,
//ActivityTypeEnum.lemmaAudio,
];
final types = List.generate(
VocabPracticeConstants.practiceGroupSize,
(_) => activityTypes[r.nextInt(activityTypes.length)],
);
final constructs = await _fetch();
final targetCount = min(constructs.length, types.length);
final targets = [
for (var i = 0; i < targetCount; i++)
PracticeTarget(
tokens: [constructs[i].asToken],
activityType: types[i],
),
];
final session = VocabPracticeSessionModel(
userL1: MatrixState.pangeaController.userController.userL1!.langCode,
userL2: MatrixState.pangeaController.userController.userL2!.langCode,
startedAt: DateTime.now(),
practiceTargets: targets,
);
await _setCached(session);
return session;
}
static Future<void> update(
VocabPracticeSessionModel session,
) =>
_setCached(session);
static Future<void> clear() => _storage.erase();
static Future<List<ConstructIdentifier>> _fetch() async {
final constructs = await MatrixState
.pangeaController.matrixState.analyticsDataService
.getAggregatedConstructs(ConstructTypeEnum.vocab)
.then((map) => map.values.toList());
// sort by last used descending, nulls first
constructs.sort((a, b) {
final dateA = a.lastUsed;
final dateB = b.lastUsed;
if (dateA == null && dateB == null) return 0;
if (dateA == null) return -1;
if (dateB == null) return 1;
return dateA.compareTo(dateB);
});
return constructs
.where((construct) => construct.lemma.isNotEmpty)
.take(VocabPracticeConstants.practiceGroupSize)
.map((construct) => construct.id)
.toList();
}
static VocabPracticeSessionModel? _getCached() {
final keys = List<String>.from(_storage.getKeys());
if (keys.isEmpty) return null;
try {
final json = _storage.read(keys.first) as Map<String, dynamic>;
return VocabPracticeSessionModel.fromJson(json);
} catch (e) {
_storage.remove(keys.first);
return null;
}
}
static Future<void> _setCached(VocabPracticeSessionModel session) async {
await _storage.erase();
await _storage.write(
session.startedAt.toIso8601String(),
session.toJson(),
);
}
}