diff --git a/lib/pangea/analytics_data/analytics_data_service.dart b/lib/pangea/analytics_data/analytics_data_service.dart index 56d84ba7f..b339c8638 100644 --- a/lib/pangea/analytics_data/analytics_data_service.dart +++ b/lib/pangea/analytics_data/analytics_data_service.dart @@ -124,7 +124,10 @@ class AnalyticsDataService { _invalidateCaches(); final analyticsUserId = await _analyticsClientGetter.database.getUserID(); - if (analyticsUserId != client.userID) { + final lastUpdated = + await _analyticsClientGetter.database.getLastUpdated(); + + if (analyticsUserId != client.userID || lastUpdated == null) { await _clearDatabase(); await _analyticsClientGetter.database.updateUserID(client.userID!); } @@ -253,6 +256,8 @@ class AnalyticsDataService { final Map cappedLastUseCache = {}; for (final use in uses) { if (blocked.contains(use.identifier)) continue; + if (use.category == 'other') continue; + if (!cappedLastUseCache.containsKey(use.identifier)) { final constructs = await getConstructUse(use.identifier); cappedLastUseCache[use.identifier] = constructs.cappedLastUse; @@ -324,7 +329,8 @@ class AnalyticsDataService { final existing = cleaned[canonical]; if (existing != null) { existing.merge(entry); - } else if (!blocked.contains(canonical)) { + } else if (!blocked.contains(canonical) && + canonical.category != 'other') { cleaned[canonical] = entry; } } @@ -345,7 +351,10 @@ class AnalyticsDataService { final blocked = blockedConstructs; final uses = newConstructs .where( - (c) => c.constructType == type && !blocked.contains(c.identifier), + (c) => + c.constructType == type && + !blocked.contains(c.identifier) && + c.identifier.category != 'other', ) .toList(); diff --git a/lib/pangea/analytics_data/analytics_database.dart b/lib/pangea/analytics_data/analytics_database.dart index 5fe681042..870bd9f61 100644 --- a/lib/pangea/analytics_data/analytics_database.dart +++ b/lib/pangea/analytics_data/analytics_database.dart @@ -178,6 +178,12 @@ class AnalyticsDatabase with DatabaseFileStorage { Future getUserID() => _lastEventTimestampBox.get('user_id'); + Future getLastUpdated() async { + final entry = await _lastEventTimestampBox.get('last_updated'); + if (entry == null) return null; + return DateTime.tryParse(entry); + } + Future getLastEventTimestamp() async { final timestampString = await _lastEventTimestampBox.get('last_event_timestamp'); @@ -482,6 +488,15 @@ class AnalyticsDatabase with DatabaseFileStorage { }); } + Future updateLastUpdated(DateTime timestamp) { + return _transaction(() async { + await _lastEventTimestampBox.put( + 'last_updated', + timestamp.toIso8601String(), + ); + }); + } + Future updateXPOffset(int offset) { return _transaction(() async { final stats = await getDerivedStats(); @@ -577,6 +592,8 @@ class AnalyticsDatabase with DatabaseFileStorage { ); }); + await updateLastUpdated(DateTime.now()); + stopwatch.stop(); Logs().i( "Server analytics update took ${stopwatch.elapsedMilliseconds} ms", @@ -631,6 +648,8 @@ class AnalyticsDatabase with DatabaseFileStorage { } }); + await updateLastUpdated(DateTime.now()); + stopwatch.stop(); Logs().i("Local analytics update took ${stopwatch.elapsedMilliseconds} ms"); } diff --git a/lib/pangea/analytics_data/construct_merge_table.dart b/lib/pangea/analytics_data/construct_merge_table.dart index b84786568..77cb2b065 100644 --- a/lib/pangea/analytics_data/construct_merge_table.dart +++ b/lib/pangea/analytics_data/construct_merge_table.dart @@ -1,5 +1,3 @@ -import 'package:collection/collection.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/constructs_model.dart'; @@ -7,7 +5,6 @@ import 'package:fluffychat/pangea/constructs/construct_identifier.dart'; class ConstructMergeTable { Map> lemmaTypeGroups = {}; - Map otherToSpecific = {}; final Map caseInsensitive = {}; void addConstructs( @@ -27,6 +24,8 @@ class ConstructMergeTable { for (final use in uses) { final id = use.identifier; if (exclude.contains(id)) continue; + if (id.category == 'other') continue; + final composite = id.compositeKey; (lemmaTypeGroups[composite] ??= {}).add(id); } @@ -34,6 +33,8 @@ class ConstructMergeTable { for (final use in uses) { final id = use.identifier; if (exclude.contains(id)) continue; + if (id.category == 'other') continue; + final group = lemmaTypeGroups[id.compositeKey]; if (group == null) continue; final matches = group.where((m) => m != id && m.string == id.string); @@ -42,20 +43,6 @@ class ConstructMergeTable { caseInsensitive[id] = id; } } - - for (final use in uses) { - if (exclude.contains(use.identifier)) continue; - final id = use.identifier; - final composite = id.compositeKey; - if (id.category == 'other' && !otherToSpecific.containsKey(id)) { - final specific = lemmaTypeGroups[composite]!.firstWhereOrNull( - (k) => k.category != 'other', - ); - if (specific != null) { - otherToSpecific[id] = caseInsensitive[specific] ?? specific; - } - } - } } void removeConstruct(ConstructIdentifier id) { @@ -68,17 +55,6 @@ class ConstructMergeTable { lemmaTypeGroups.remove(composite); } - if (id.category != 'other') { - final otherId = ConstructIdentifier( - lemma: id.lemma, - type: id.type, - category: 'other', - ); - otherToSpecific.remove(otherId); - } else { - otherToSpecific.remove(id); - } - final caseEntry = caseInsensitive[id]; if (caseEntry != null && caseEntry != id) { caseInsensitive.remove(caseEntry); @@ -87,8 +63,7 @@ class ConstructMergeTable { } ConstructIdentifier resolve(ConstructIdentifier key) { - final specific = otherToSpecific[key] ?? key; - return caseInsensitive[specific] ?? specific; + return caseInsensitive[key] ?? key; } List groupedIds( @@ -96,10 +71,12 @@ class ConstructMergeTable { Set exclude, ) { final keys = []; - if (!exclude.contains(id)) { - keys.add(id); + if (exclude.contains(id) || id.category == 'other') { + return keys; } + keys.add(id); + // if this key maps to a different case variant, include that as well final differentCase = caseInsensitive[id]; if (differentCase != null && differentCase != id) { @@ -108,28 +85,6 @@ class ConstructMergeTable { } } - // if this is an broad ('other') key, find the specific key it maps to - // and include it if available - if (id.category == 'other') { - final specificKey = otherToSpecific[id]; - if (specificKey != null) { - keys.add(specificKey); - } - return keys; - } - - // if this is a specific key, and there existing an 'other' construct - // in the same group, and that 'other' construct maps to this specific key, - // include the 'other' construct as well - final otherEntry = lemmaTypeGroups[id.compositeKey] - ?.firstWhereOrNull((k) => k.category == 'other'); - if (otherEntry == null) { - return keys; - } - - if (otherToSpecific[otherEntry] == id) { - keys.add(otherEntry); - } return keys; } @@ -152,7 +107,6 @@ class ConstructMergeTable { void clear() { lemmaTypeGroups.clear(); - otherToSpecific.clear(); caseInsensitive.clear(); } } diff --git a/lib/pangea/analytics_practice/analytics_practice_constants.dart b/lib/pangea/analytics_practice/analytics_practice_constants.dart index 3d049b597..a8e06083c 100644 --- a/lib/pangea/analytics_practice/analytics_practice_constants.dart +++ b/lib/pangea/analytics_practice/analytics_practice_constants.dart @@ -1,4 +1,5 @@ class AnalyticsPracticeConstants { static const int timeForBonus = 60; static const int practiceGroupSize = 10; + static const int errorBufferSize = 5; } diff --git a/lib/pangea/analytics_practice/analytics_practice_page.dart b/lib/pangea/analytics_practice/analytics_practice_page.dart index 9329c5170..44b471f92 100644 --- a/lib/pangea/analytics_practice/analytics_practice_page.dart +++ b/lib/pangea/analytics_practice/analytics_practice_page.dart @@ -10,6 +10,8 @@ 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_session_model.dart'; import 'package:fluffychat/pangea/analytics_practice/analytics_practice_session_repo.dart'; @@ -317,19 +319,27 @@ class AnalyticsPracticeState extends State if (activityState.value is AsyncIdle) { await _initActivityData(); - } else if (_queue.isEmpty) { - await _completeSession(); } else { - activityState.value = const AsyncState.loading(); - selectedMorphChoice.value = null; - final nextActivityCompleter = _queue.removeFirst(); + // 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; + final nextActivityCompleter = _queue.removeFirst(); - activityTarget.value = nextActivityCompleter.request; - _playAudio(); - - final activity = await nextActivityCompleter.completer.future; - activityState.value = AsyncState.loaded(activity); - AnalyticsPractice.bypassExitConfirmation = false; + 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 + await _completeSession(); } } catch (e) { AnalyticsPractice.bypassExitConfirmation = true; @@ -345,26 +355,30 @@ class AnalyticsPracticeState extends State throw L10n.of(context).noActivityRequest; } - try { - activityState.value = const AsyncState.loading(); - final req = requests.first; - - activityTarget.value = req; - _playAudio(); - - final res = await _fetchActivity(req); - if (!mounted) return; - - activityState.value = AsyncState.loaded(res); - AnalyticsPractice.bypassExitConfirmation = false; - } catch (e) { - AnalyticsPractice.bypassExitConfirmation = true; - if (!mounted) return; - activityState.value = AsyncState.error(e); - return; + 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; + } } - - _fillActivityQueue(requests.skip(1).toList()); + AnalyticsPractice.bypassExitConfirmation = true; + if (!mounted) return; + activityState.value = + AsyncState.error(L10n.of(context).oopsSomethingWentWrong); + return; } Future _fillActivityQueue( @@ -378,7 +392,6 @@ class AnalyticsPracticeState extends State completer: completer, ), ); - try { final res = await _fetchActivity(request); if (!mounted) return; @@ -386,7 +399,7 @@ class AnalyticsPracticeState extends State } catch (e) { if (!mounted) return; completer.completeError(e); - break; + await recordSkippedUse(request); } } } @@ -443,6 +456,27 @@ class AnalyticsPracticeState extends State _choiceEmojis[requestKey]!.addAll(emojis); } + Future 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]); + } + Future onSelectChoice( String choiceContent, ) async { @@ -484,7 +518,13 @@ class AnalyticsPracticeState extends State _sessionLoader.value!.completeActivity(); progressNotifier.value = _sessionLoader.value!.progress; - _isComplete ? await _completeSession() : await _continueSession(); + if (_queue.isEmpty) { + await _completeSession(); + } else if (_isComplete) { + await _completeSession(); + } else { + await _continueSession(); + } } Future?> getExampleMessage( diff --git a/lib/pangea/analytics_practice/analytics_practice_session_model.dart b/lib/pangea/analytics_practice/analytics_practice_session_model.dart index e0ee08718..9ab395a13 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_model.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_model.dart @@ -1,5 +1,3 @@ -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/analytics_practice/analytics_practice_constants.dart'; @@ -45,15 +43,33 @@ class AnalyticsPracticeSessionModel { AnalyticsPracticeSessionState? state, }) : state = state ?? const AnalyticsPracticeSessionState(); - int get _availableActivities => min( - AnalyticsPracticeConstants.practiceGroupSize, - practiceTargets.length, - ); + // Maximum activities to attempt (including skips) + int get _maxAttempts => (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize) + .clamp(0, practiceTargets.length) + .toInt(); - bool get isComplete => state.currentIndex >= _availableActivities; + int get _completionGoal => AnalyticsPracticeConstants.practiceGroupSize + .clamp(0, practiceTargets.length); - double get progress => - (state.currentIndex / _availableActivities).clamp(0.0, 1.0); + // Total attempted so far (completed + skipped) + int get _totalAttempted => state.currentIndex + state.skippedActivities; + + bool get isComplete { + final complete = state.finished || + state.currentIndex >= _completionGoal || + _totalAttempted >= _maxAttempts; + return complete; + } + + double get progress { + final possibleCompletions = + (state.currentIndex + _maxAttempts - _totalAttempted) + .clamp(0, _completionGoal); + return possibleCompletions > 0 + ? (state.currentIndex / possibleCompletions).clamp(0.0, 1.0) + : 1.0; + } List get activityRequests { return practiceTargets.map((target) { @@ -75,6 +91,10 @@ class AnalyticsPracticeSessionModel { void completeActivity() => state = state.copyWith(currentIndex: state.currentIndex + 1); + void incrementSkippedActivities() => state = state.copyWith( + skippedActivities: state.skippedActivities + 1, + ); + void submitAnswer(OneConstructUse use) => state = state.copyWith( completedUses: [...state.completedUses, use], ); @@ -110,12 +130,14 @@ class AnalyticsPracticeSessionState { final int currentIndex; final bool finished; final int elapsedSeconds; + final int skippedActivities; const AnalyticsPracticeSessionState({ this.completedUses = const [], this.currentIndex = 0, this.finished = false, this.elapsedSeconds = 0, + this.skippedActivities = 0, }); int get totalXpGained => completedUses.fold(0, (sum, use) => sum + use.xp); @@ -168,12 +190,14 @@ class AnalyticsPracticeSessionState { int? currentIndex, bool? finished, int? elapsedSeconds, + int? skippedActivities, }) { return AnalyticsPracticeSessionState( completedUses: completedUses ?? this.completedUses, currentIndex: currentIndex ?? this.currentIndex, finished: finished ?? this.finished, elapsedSeconds: elapsedSeconds ?? this.elapsedSeconds, + skippedActivities: skippedActivities ?? this.skippedActivities, ); } @@ -183,6 +207,7 @@ class AnalyticsPracticeSessionState { 'currentIndex': currentIndex, 'finished': finished, 'elapsedSeconds': elapsedSeconds, + 'skippedActivities': skippedActivities, }; } @@ -196,6 +221,7 @@ class AnalyticsPracticeSessionState { currentIndex: json['currentIndex'] as int? ?? 0, finished: json['finished'] as bool? ?? false, elapsedSeconds: json['elapsedSeconds'] as int? ?? 0, + skippedActivities: json['skippedActivities'] as int? ?? 0, ); } } diff --git a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart index 6a6ef66cf..c2c507741 100644 --- a/lib/pangea/analytics_practice/analytics_practice_session_repo.dart +++ b/lib/pangea/analytics_practice/analytics_practice_session_repo.dart @@ -34,7 +34,8 @@ class AnalyticsPracticeSessionRepo { final activityTypes = ActivityTypeEnum.analyticsPracticeTypes(type); final types = List.generate( - AnalyticsPracticeConstants.practiceGroupSize, + AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize, (_) => activityTypes[r.nextInt(activityTypes.length)], ); @@ -55,11 +56,13 @@ class AnalyticsPracticeSessionRepo { } else { final errorTargets = await _fetchErrors(); targets.addAll(errorTargets); - - if (targets.length < AnalyticsPracticeConstants.practiceGroupSize) { + if (targets.length < + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { final morphs = await _fetchMorphs(); - final remainingCount = - AnalyticsPracticeConstants.practiceGroupSize - targets.length; + final remainingCount = (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize) - + targets.length; final morphEntries = morphs.entries.take(remainingCount); for (final entry in morphEntries) { @@ -113,7 +116,9 @@ class AnalyticsPracticeSessionRepo { if (seemLemmas.contains(construct.lemma)) continue; seemLemmas.add(construct.lemma); targets.add(construct.id); - if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) { + if (targets.length >= + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { break; } } @@ -140,7 +145,9 @@ class AnalyticsPracticeSessionRepo { final Set seenForms = {}; for (final entry in constructs) { - if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) { + if (targets.length >= + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { break; } @@ -150,7 +157,9 @@ class AnalyticsPracticeSessionRepo { } for (final use in entry.cappedUses) { - if (targets.length >= AnalyticsPracticeConstants.practiceGroupSize) { + if (targets.length >= + (AnalyticsPracticeConstants.practiceGroupSize + + AnalyticsPracticeConstants.errorBufferSize)) { break; } diff --git a/lib/pangea/analytics_practice/grammar_error_practice_generator.dart b/lib/pangea/analytics_practice/grammar_error_practice_generator.dart index 854d1b66a..9284a3c9f 100644 --- a/lib/pangea/analytics_practice/grammar_error_practice_generator.dart +++ b/lib/pangea/analytics_practice/grammar_error_practice_generator.dart @@ -31,7 +31,10 @@ class GrammarErrorPracticeGenerator { .take(igcMatch.length) .toString(); - choices.add(errorSpan); + if (!req.grammarErrorInfo!.translation.contains(errorSpan)) { + choices.add(errorSpan); + } + choices.shuffle(); return MessageActivityResponse( activity: GrammarErrorPracticeActivityModel(