Merge branch 'main' into 1014-overflow-when-audio-activity-opens
This commit is contained in:
commit
d507f494e5
17 changed files with 620 additions and 749 deletions
|
|
@ -23,7 +23,7 @@ void main() async {
|
|||
|
||||
// #Pangea
|
||||
try {
|
||||
await dotenv.load(fileName: ".env");
|
||||
await dotenv.load(fileName: ".env.local_choreo");
|
||||
} catch (e) {
|
||||
Logs().e('Failed to load .env file', e);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_e
|
|||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/models/choreo_record.dart';
|
||||
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/models/representation_content_model.dart';
|
||||
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
|
||||
import 'package:fluffychat/pangea/utils/error_handler.dart';
|
||||
|
|
@ -1660,6 +1661,7 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
// #Pangea
|
||||
void showToolbar(
|
||||
PangeaMessageEvent pangeaMessageEvent, {
|
||||
PangeaToken? selectedToken,
|
||||
MessageMode? mode,
|
||||
Event? nextEvent,
|
||||
Event? prevEvent,
|
||||
|
|
@ -1692,6 +1694,7 @@ class ChatController extends State<ChatPageWithRoom>
|
|||
chatController: this,
|
||||
event: pangeaMessageEvent.event,
|
||||
pangeaMessageEvent: pangeaMessageEvent,
|
||||
selectedTokenOnInitialization: selectedToken,
|
||||
nextEvent: nextEvent,
|
||||
prevEvent: prevEvent,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:fluffychat/pages/chat/chat.dart';
|
|||
import 'package:fluffychat/pages/chat/events/video_player.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
|
||||
import 'package:fluffychat/pangea/widgets/chat/message_token_text_stateful.dart';
|
||||
import 'package:fluffychat/pangea/widgets/chat/message_token_text.dart';
|
||||
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_selection_area.dart';
|
||||
import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart';
|
||||
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
|
||||
|
|
@ -306,25 +306,6 @@ class MessageContent extends StatelessWidget {
|
|||
height: 1.3,
|
||||
);
|
||||
|
||||
if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens !=
|
||||
null) {
|
||||
return MessageTokenText(
|
||||
messageAnalyticsEntry:
|
||||
controller.pangeaController.getAnalytics.perMessage.get(
|
||||
pangeaMessageEvent!,
|
||||
false,
|
||||
)!,
|
||||
style: messageTextStyle,
|
||||
onClick: overlayController?.onClickOverlayMessageToken ??
|
||||
(_) => controller.showToolbar(pangeaMessageEvent!),
|
||||
isSelected: overlayController?.isTokenSelected,
|
||||
);
|
||||
}
|
||||
|
||||
if (overlayController != null && pangeaMessageEvent != null) {
|
||||
return overlayController!.messageTokenText;
|
||||
}
|
||||
|
||||
if (immersionMode && pangeaMessageEvent != null) {
|
||||
return Flexible(
|
||||
child: PangeaRichText(
|
||||
|
|
@ -336,6 +317,20 @@ class MessageContent extends StatelessWidget {
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (pangeaMessageEvent != null) {
|
||||
return MessageTokenText(
|
||||
pangeaMessageEvent: pangeaMessageEvent!,
|
||||
tokens:
|
||||
pangeaMessageEvent!.messageDisplayRepresentation?.tokens,
|
||||
style: messageTextStyle,
|
||||
onClick: overlayController?.onClickOverlayMessageToken ??
|
||||
(token) => controller.showToolbar(pangeaMessageEvent!,
|
||||
selectedToken: token),
|
||||
isSelected: overlayController?.isTokenSelected,
|
||||
);
|
||||
}
|
||||
|
||||
// Pangea#
|
||||
|
||||
return
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import 'dart:math';
|
|||
|
||||
import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Picks which tokens to do activities on and what types of activities to do
|
||||
|
|
@ -11,125 +11,176 @@ import 'package:flutter/foundation.dart';
|
|||
/// Most importantly, we can't do this in the state of a message widget because the state is disposed of and recreated
|
||||
/// If we decided that the first token should have a hidden word listening, we need to remember that
|
||||
/// Otherwise, the user might leave the chat, return, and see a different word hidden
|
||||
|
||||
class TargetTokensAndActivityType {
|
||||
final List<PangeaToken> tokens;
|
||||
final ActivityTypeEnum activityType;
|
||||
|
||||
TargetTokensAndActivityType({
|
||||
required this.tokens,
|
||||
required this.activityType,
|
||||
});
|
||||
|
||||
bool matchesActivity(PracticeActivityModel activity) {
|
||||
// check if the existing activity has the same type as the target
|
||||
if (activity.activityType != activityType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check that the activity matches at least one construct in the target tokens
|
||||
// TODO - this is complicated so we need to verify it works
|
||||
// maybe we just verify that the target span of the activity is the same as the target span of the target
|
||||
final allTokenConstructs =
|
||||
tokens.map((t) => t.constructs).expand((e) => e).toList();
|
||||
for (final c in allTokenConstructs) {
|
||||
if (activity.tgtConstructs.any((tc) => tc == c.id)) {
|
||||
debugPrint('found existing activity');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is TargetTokensAndActivityType &&
|
||||
listEquals(other.tokens, tokens) &&
|
||||
other.activityType == activityType;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => tokens.hashCode ^ activityType.hashCode;
|
||||
}
|
||||
|
||||
class MessageAnalyticsEntry {
|
||||
final DateTime createdAt = DateTime.now();
|
||||
|
||||
late List<TokenWithXP> tokensWithXp;
|
||||
late final List<PangeaToken> _tokens;
|
||||
|
||||
final PangeaMessageEvent pmEvent;
|
||||
late final bool _includeHiddenWordActivities;
|
||||
|
||||
//
|
||||
bool isFirstTimeComputing = true;
|
||||
late final List<TargetTokensAndActivityType> _activityQueue;
|
||||
|
||||
TokenWithXP? nextActivityToken;
|
||||
ActivityTypeEnum? nextActivityType;
|
||||
|
||||
MessageAnalyticsEntry(this.pmEvent) {
|
||||
debugPrint('making MessageAnalyticsEntry: ${pmEvent.messageDisplayText}');
|
||||
if (pmEvent.messageDisplayRepresentation?.tokens == null) {
|
||||
throw Exception('No tokens in message in MessageAnalyticsEntry');
|
||||
}
|
||||
tokensWithXp = pmEvent.messageDisplayRepresentation!.tokens!
|
||||
.map((token) => TokenWithXP(token: token))
|
||||
.toList();
|
||||
|
||||
updateTargetTypesForMessage();
|
||||
MessageAnalyticsEntry({
|
||||
required List<PangeaToken> tokens,
|
||||
required bool includeHiddenWordActivities,
|
||||
}) {
|
||||
_tokens = tokens;
|
||||
_includeHiddenWordActivities = includeHiddenWordActivities;
|
||||
_activityQueue = setActivityQueue();
|
||||
}
|
||||
|
||||
List<TokenWithXP> get tokensThatCanBeHeard =>
|
||||
tokensWithXp.where((t) => t.token.canBeHeard).toList();
|
||||
TargetTokensAndActivityType? get nextActivity =>
|
||||
_activityQueue.isNotEmpty ? _activityQueue.first : null;
|
||||
|
||||
void updateTokenTargetTypes() {
|
||||
// compute target types for each token
|
||||
for (final token in tokensWithXp) {
|
||||
token.targetTypes = [];
|
||||
bool get canDoWordFocusListening =>
|
||||
_tokens.where((t) => t.canBeHeard).length > 4;
|
||||
|
||||
if (!token.token.lemma.saveVocab) {
|
||||
continue;
|
||||
}
|
||||
/// On initialization, we pick which tokens to do activities on and what types of activities to do
|
||||
List<TargetTokensAndActivityType> setActivityQueue() {
|
||||
final List<TargetTokensAndActivityType> queue = [];
|
||||
|
||||
if (token.daysSinceLastUse < 1) {
|
||||
continue;
|
||||
}
|
||||
// for each token in the message
|
||||
// pick a random activity type from the eligible types
|
||||
for (final token in _tokens) {
|
||||
// get all the eligible activity types for the token
|
||||
// based on the context of the message
|
||||
final eligibleTypesBasedOnContext = token.eligibleActivityTypes
|
||||
.where((type) => type != ActivityTypeEnum.hiddenWordListening)
|
||||
.where(
|
||||
(type) =>
|
||||
canDoWordFocusListening ||
|
||||
type != ActivityTypeEnum.wordFocusListening,
|
||||
)
|
||||
.toList();
|
||||
|
||||
if (token.eligibleForActivity(ActivityTypeEnum.wordMeaning) &&
|
||||
!token.didActivity(ActivityTypeEnum.wordMeaning)) {
|
||||
token.targetTypes.add(ActivityTypeEnum.wordMeaning);
|
||||
}
|
||||
// if there are no eligible types, continue to the next token
|
||||
if (eligibleTypesBasedOnContext.isEmpty) continue;
|
||||
|
||||
if (token.eligibleForActivity(ActivityTypeEnum.wordFocusListening) &&
|
||||
!token.didActivity(ActivityTypeEnum.wordFocusListening) &&
|
||||
tokensThatCanBeHeard.length > 3) {
|
||||
token.targetTypes.add(ActivityTypeEnum.wordFocusListening);
|
||||
}
|
||||
|
||||
if (token.eligibleForActivity(ActivityTypeEnum.hiddenWordListening) &&
|
||||
isFirstTimeComputing &&
|
||||
!token.didActivity(ActivityTypeEnum.hiddenWordListening) &&
|
||||
!pmEvent.ownMessage) {
|
||||
token.targetTypes.add(ActivityTypeEnum.hiddenWordListening);
|
||||
}
|
||||
// chose a random activity type from the eligible types for that token
|
||||
queue.add(
|
||||
TargetTokensAndActivityType(
|
||||
tokens: [token],
|
||||
activityType: eligibleTypesBasedOnContext[
|
||||
Random().nextInt(eligibleTypesBasedOnContext.length)],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Updates the target types for each token in the message and the next
|
||||
/// activity token and type. Called before requesting the next new activity.
|
||||
void updateTargetTypesForMessage() {
|
||||
// reset
|
||||
nextActivityToken = null;
|
||||
nextActivityType = null;
|
||||
updateTokenTargetTypes();
|
||||
|
||||
// From the tokens with hiddenWordListening in targetTypes, pick one at random.
|
||||
// Create a list of token indicies with hiddenWordListening type available.
|
||||
final List<int> withHiddenWordIndices = tokensWithXp
|
||||
.asMap()
|
||||
.entries
|
||||
.where(
|
||||
(entry) => entry.value.targetTypes.contains(
|
||||
ActivityTypeEnum.hiddenWordListening,
|
||||
// sort the queue by the total xp of the tokens, lowest first
|
||||
queue.sort(
|
||||
(a, b) => a.tokens.map((t) => t.xp).reduce((a, b) => a + b).compareTo(
|
||||
b.tokens.map((t) => t.xp).reduce((a, b) => a + b),
|
||||
),
|
||||
)
|
||||
.map((entry) => entry.key)
|
||||
.toList();
|
||||
);
|
||||
|
||||
// randomly pick one index in the list and set the next activity
|
||||
if (withHiddenWordIndices.isNotEmpty) {
|
||||
final int randomIndex =
|
||||
withHiddenWordIndices[Random().nextInt(withHiddenWordIndices.length)];
|
||||
// if applicable, add a hidden word activity to the front of the queue
|
||||
final hiddenWordActivity = getHiddenWordActivity(queue.length);
|
||||
if (hiddenWordActivity != null) {
|
||||
queue.insert(0, hiddenWordActivity);
|
||||
}
|
||||
|
||||
nextActivityToken = tokensWithXp[randomIndex];
|
||||
nextActivityType = ActivityTypeEnum.hiddenWordListening;
|
||||
// limit to 3 activities
|
||||
return queue.take(3).toList();
|
||||
}
|
||||
|
||||
// remove hiddenWord type from all other tokens
|
||||
// there can only be one hidden word activity for a message
|
||||
for (int i = 0; i < tokensWithXp.length; i++) {
|
||||
if (i != randomIndex) {
|
||||
tokensWithXp[i]
|
||||
.targetTypes
|
||||
.remove(ActivityTypeEnum.hiddenWordListening);
|
||||
TargetTokensAndActivityType? getHiddenWordActivity(int numOtherActivities) {
|
||||
// don't do hidden word listening on own messages
|
||||
if (!_includeHiddenWordActivities) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// we will only do hidden word listening 50% of the time
|
||||
// if there are no other activities to do, we will always do hidden word listening
|
||||
if (numOtherActivities >= 3 && Random().nextDouble() < 0.5) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// We will find the longest sequence of tokens that have hiddenWordListening in their eligibleActivityTypes
|
||||
final List<List<PangeaToken>> sequences = [];
|
||||
List<PangeaToken> currentSequence = [];
|
||||
for (final token in _tokens) {
|
||||
if (token.eligibleActivityTypes
|
||||
.contains(ActivityTypeEnum.hiddenWordListening)) {
|
||||
currentSequence.add(token);
|
||||
} else {
|
||||
if (currentSequence.isNotEmpty) {
|
||||
sequences.add(currentSequence);
|
||||
currentSequence = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if we didn't find any hiddenWordListening,
|
||||
// pick the first token that has a target type
|
||||
nextActivityToken ??=
|
||||
tokensWithXp.where((t) => t.targetTypes.isNotEmpty).firstOrNull;
|
||||
nextActivityType ??= nextActivityToken?.targetTypes.firstOrNull;
|
||||
if (sequences.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
isFirstTimeComputing = false;
|
||||
final longestSequence = sequences.reduce(
|
||||
(a, b) => a.length > b.length ? a : b,
|
||||
);
|
||||
|
||||
return TargetTokensAndActivityType(
|
||||
tokens: longestSequence,
|
||||
activityType: ActivityTypeEnum.hiddenWordListening,
|
||||
);
|
||||
}
|
||||
|
||||
void onActivityComplete(PracticeActivityModel completed) {
|
||||
_activityQueue.removeWhere(
|
||||
(a) => a.matchesActivity(completed),
|
||||
);
|
||||
}
|
||||
|
||||
void revealAllTokens() {
|
||||
for (final token in tokensWithXp) {
|
||||
token.targetTypes.remove(ActivityTypeEnum.hiddenWordListening);
|
||||
}
|
||||
_activityQueue.removeWhere((a) => a.activityType.hiddenType);
|
||||
}
|
||||
|
||||
bool get shouldHideToken => tokensWithXp.any(
|
||||
(token) =>
|
||||
token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening),
|
||||
bool isTokenInHiddenWordActivity(PangeaToken token) => _activityQueue.any(
|
||||
(activity) =>
|
||||
activity.tokens.contains(token) && activity.activityType.hiddenType,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -156,22 +207,25 @@ class MessageAnalyticsController {
|
|||
}
|
||||
}
|
||||
|
||||
String _key(List<PangeaToken> tokens) => PangeaToken.reconstructText(tokens);
|
||||
|
||||
MessageAnalyticsEntry? get(
|
||||
PangeaMessageEvent pmEvent,
|
||||
bool refresh,
|
||||
List<PangeaToken> tokens,
|
||||
bool includeHiddenWordActivities,
|
||||
) {
|
||||
if (pmEvent.messageDisplayRepresentation?.tokens == null) {
|
||||
return null;
|
||||
final String key = _key(tokens);
|
||||
|
||||
if (_cache.containsKey(key)) {
|
||||
return _cache[key];
|
||||
}
|
||||
|
||||
if (_cache.containsKey(pmEvent.messageDisplayText) && !refresh) {
|
||||
return _cache[pmEvent.messageDisplayText];
|
||||
}
|
||||
|
||||
_cache[pmEvent.messageDisplayText] = MessageAnalyticsEntry(pmEvent);
|
||||
_cache[key] = MessageAnalyticsEntry(
|
||||
tokens: tokens,
|
||||
includeHiddenWordActivities: includeHiddenWordActivities,
|
||||
);
|
||||
|
||||
clean();
|
||||
|
||||
return _cache[pmEvent.messageDisplayText];
|
||||
return _cache[key];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,17 +90,10 @@ class PracticeGenerationController {
|
|||
|
||||
final response = MessageActivityResponse.fromJson(json);
|
||||
|
||||
// workaround for the server not returning the tgtConstructs
|
||||
// if (response.activity != null &&
|
||||
// response.activity!.tgtConstructs.isEmpty) {
|
||||
// response.activity?.tgtConstructs.addAll(
|
||||
// requestModel.clientTokenRequest.constructIDs,
|
||||
// );
|
||||
// }
|
||||
return response;
|
||||
} else {
|
||||
debugger(when: kDebugMode);
|
||||
throw Exception('Failed to convert speech to text');
|
||||
throw Exception('Failed to create activity');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -123,49 +116,15 @@ class PracticeGenerationController {
|
|||
requestModel: req,
|
||||
);
|
||||
|
||||
if (res.finished) {
|
||||
debugPrint('Activity generation finished');
|
||||
return null;
|
||||
}
|
||||
|
||||
final eventCompleter = Completer<PracticeActivityEvent?>();
|
||||
|
||||
// if the server points to an existing event, return that event
|
||||
if (res.existingActivityEventId != null) {
|
||||
final Event? existingEvent =
|
||||
await event.room.getEventById(res.existingActivityEventId!);
|
||||
|
||||
debugPrint(
|
||||
'Existing activity event found: ${existingEvent?.content}',
|
||||
);
|
||||
debugPrint(
|
||||
"eventID: ${existingEvent?.eventId}, event is redacted: ${existingEvent?.redacted}",
|
||||
);
|
||||
if (existingEvent != null && !existingEvent.redacted) {
|
||||
final activityEvent = PracticeActivityEvent(
|
||||
event: existingEvent,
|
||||
timeline: event.timeline,
|
||||
);
|
||||
eventCompleter.complete(activityEvent);
|
||||
return PracticeActivityModelResponse(
|
||||
activity: activityEvent.practiceActivity,
|
||||
eventCompleter: eventCompleter,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (res.activity == null) {
|
||||
debugPrint('No activity generated');
|
||||
return null;
|
||||
}
|
||||
|
||||
debugPrint('Activity generated: ${res.activity!.toJson()}');
|
||||
_sendAndPackageEvent(res.activity!, event).then((event) {
|
||||
debugPrint('Activity generated: ${res.activity.toJson()}');
|
||||
_sendAndPackageEvent(res.activity, event).then((event) {
|
||||
eventCompleter.complete(event);
|
||||
});
|
||||
|
||||
final responseModel = PracticeActivityModelResponse(
|
||||
activity: res.activity!,
|
||||
activity: res.activity,
|
||||
eventCompleter: eventCompleter,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,16 @@ extension ActivityTypeExtension on ActivityTypeEnum {
|
|||
}
|
||||
}
|
||||
|
||||
bool get hiddenType {
|
||||
switch (this) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return false;
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
ActivityTypeEnum fromString(String value) {
|
||||
final split = value.split('.').last;
|
||||
switch (split) {
|
||||
|
|
@ -42,19 +52,19 @@ extension ActivityTypeExtension on ActivityTypeEnum {
|
|||
return [
|
||||
ConstructUseTypeEnum.corPA,
|
||||
ConstructUseTypeEnum.incPA,
|
||||
ConstructUseTypeEnum.ignPA
|
||||
ConstructUseTypeEnum.ignPA,
|
||||
];
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return [
|
||||
ConstructUseTypeEnum.corWL,
|
||||
ConstructUseTypeEnum.incWL,
|
||||
ConstructUseTypeEnum.ignWL
|
||||
ConstructUseTypeEnum.ignWL,
|
||||
];
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return [
|
||||
ConstructUseTypeEnum.corHWL,
|
||||
ConstructUseTypeEnum.incHWL,
|
||||
ConstructUseTypeEnum.ignHWL
|
||||
ConstructUseTypeEnum.ignHWL,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import 'dart:developer';
|
|||
|
||||
import 'package:fluffychat/pangea/extensions/pangea_event_extension.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_event.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
|
@ -76,11 +75,4 @@ class PracticeActivityEvent {
|
|||
// DateTime? get lastCompletedAt => latestUserRecord?.event.originServerTs;
|
||||
|
||||
String get parentMessageId => event.relationshipEventId!;
|
||||
|
||||
ExistingActivityMetaData get activityRequestMetaData =>
|
||||
ExistingActivityMetaData(
|
||||
activityEventId: event.eventId,
|
||||
tgtConstructs: practiceActivity.tgtConstructs,
|
||||
activityType: practiceActivity.activityType,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import 'dart:developer';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/enum/construct_use_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../constants/model_keys.dart';
|
||||
|
|
@ -31,6 +35,18 @@ class PangeaToken {
|
|||
required this.morph,
|
||||
});
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is PangeaToken) {
|
||||
return other.text.content == text.content &&
|
||||
other.text.offset == text.offset;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => text.content.hashCode ^ text.offset.hashCode;
|
||||
|
||||
/// reconstructs the text from the tokens
|
||||
/// [tokens] - the tokens to reconstruct
|
||||
/// [debugWalkThrough] - if true, will start the debugger
|
||||
|
|
@ -151,6 +167,186 @@ class PangeaToken {
|
|||
category: pos,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isActivityBasicallyEligible(ActivityTypeEnum a) {
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
// return isContentWord;
|
||||
return true;
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return canBeHeard;
|
||||
}
|
||||
}
|
||||
|
||||
bool _didActivity(ActivityTypeEnum a) {
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return vocabConstruct.uses
|
||||
.map((u) => u.useType)
|
||||
.any((u) => a.associatedUseTypes.contains(u));
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return vocabConstruct.uses
|
||||
// TODO - double-check that form is going to be available here
|
||||
// .where((u) =>
|
||||
// u.form?.toLowerCase() == text.content.toLowerCase(),)
|
||||
.map((u) => u.useType)
|
||||
.any((u) => a.associatedUseTypes.contains(u));
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return vocabConstruct.uses
|
||||
// TODO - double-check that form is going to be available here
|
||||
// .where((u) =>
|
||||
// u.form?.toLowerCase() == text.content.toLowerCase(),)
|
||||
.map((u) => u.useType)
|
||||
.any((u) => a.associatedUseTypes.contains(u));
|
||||
}
|
||||
}
|
||||
|
||||
bool _didActivitySuccessfully(ActivityTypeEnum a) {
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return vocabConstruct.uses
|
||||
.map((u) => u.useType)
|
||||
.any((u) => u == ConstructUseTypeEnum.corPA);
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return vocabConstruct.uses
|
||||
// TODO - double-check that form is going to be available here
|
||||
// .where((u) =>
|
||||
// u.form?.toLowerCase() == text.content.toLowerCase(),)
|
||||
.map((u) => u.useType)
|
||||
.any((u) => u == ConstructUseTypeEnum.corWL);
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return vocabConstruct.uses
|
||||
// TODO - double-check that form is going to be available here
|
||||
// .where((u) =>
|
||||
// u.form?.toLowerCase() == text.content.toLowerCase(),)
|
||||
.map((u) => u.useType)
|
||||
.any((u) => u == ConstructUseTypeEnum.corHWL);
|
||||
}
|
||||
}
|
||||
|
||||
bool isActivityProbablyLevelAppropriate(ActivityTypeEnum a) {
|
||||
debugger(when: kDebugMode);
|
||||
final int points = vocabConstruct.points;
|
||||
final int myxp = xp;
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return vocabConstruct.points < 15;
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return !_didActivitySuccessfully(a);
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
bool shouldDoActivity(ActivityTypeEnum a) {
|
||||
final bool notEmpty = text.content.trim().isNotEmpty;
|
||||
final bool isEligible = _isActivityBasicallyEligible(a);
|
||||
final bool isProbablyLevelAppropriate =
|
||||
isActivityProbablyLevelAppropriate(a);
|
||||
|
||||
return notEmpty && isEligible && isProbablyLevelAppropriate;
|
||||
}
|
||||
|
||||
List<ActivityTypeEnum> get eligibleActivityTypes {
|
||||
final List<ActivityTypeEnum> eligibleActivityTypes = [];
|
||||
|
||||
if (!lemma.saveVocab || daysSinceLastUse < 1) {
|
||||
return eligibleActivityTypes;
|
||||
}
|
||||
|
||||
for (final type in ActivityTypeEnum.values) {
|
||||
if (_isActivityBasicallyEligible(type) &&
|
||||
!_didActivitySuccessfully(type)) {
|
||||
eligibleActivityTypes.add(type);
|
||||
}
|
||||
}
|
||||
|
||||
return eligibleActivityTypes;
|
||||
}
|
||||
|
||||
ConstructUses get vocabConstruct {
|
||||
final vocab = constructs.firstWhereOrNull(
|
||||
(element) => element.id.type == ConstructTypeEnum.vocab,
|
||||
);
|
||||
if (vocab == null) {
|
||||
return ConstructUses(
|
||||
lemma: lemma.text,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
category: pos,
|
||||
uses: [],
|
||||
);
|
||||
}
|
||||
return vocab;
|
||||
}
|
||||
|
||||
int get xp {
|
||||
return constructs.fold<int>(
|
||||
0,
|
||||
(previousValue, element) => previousValue + element.points,
|
||||
);
|
||||
}
|
||||
|
||||
///
|
||||
DateTime? get lastUsed => constructs.fold<DateTime?>(
|
||||
null,
|
||||
(previousValue, element) {
|
||||
if (previousValue == null) return element.lastUsed;
|
||||
if (element.lastUsed == null) return previousValue;
|
||||
return element.lastUsed!.isAfter(previousValue)
|
||||
? element.lastUsed
|
||||
: previousValue;
|
||||
},
|
||||
);
|
||||
|
||||
/// daysSinceLastUse
|
||||
int get daysSinceLastUse {
|
||||
if (lastUsed == null) return 1000;
|
||||
return DateTime.now().difference(lastUsed!).inDays;
|
||||
}
|
||||
|
||||
List<ConstructIdentifier> get _constructIDs {
|
||||
final List<ConstructIdentifier> ids = [];
|
||||
ids.add(
|
||||
ConstructIdentifier(
|
||||
lemma: lemma.text,
|
||||
type: ConstructTypeEnum.vocab,
|
||||
category: pos,
|
||||
),
|
||||
);
|
||||
for (final morph in morph.entries) {
|
||||
ids.add(
|
||||
ConstructIdentifier(
|
||||
lemma: morph.value,
|
||||
type: ConstructTypeEnum.morph,
|
||||
category: morph.key,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
List<ConstructUses> get constructs => _constructIDs
|
||||
.map(
|
||||
(id) =>
|
||||
MatrixState.pangeaController.getAnalytics.constructListModel
|
||||
.getConstructUses(id) ??
|
||||
ConstructUses(
|
||||
lemma: id.lemma,
|
||||
constructType: id.type,
|
||||
category: id.category,
|
||||
uses: [],
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
Map<String, dynamic> toServerChoiceTokenWithXP() {
|
||||
return {
|
||||
'token': toJson(),
|
||||
'constructs_with_xp': constructs.map((e) => e.toJson()).toList(),
|
||||
'target_types': eligibleActivityTypes.map((e) => e.string).toList(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class PangeaTokenText {
|
||||
|
|
|
|||
|
|
@ -1,193 +1,7 @@
|
|||
import 'package:collection/collection.dart';
|
||||
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/models/analytics/construct_use_model.dart';
|
||||
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class TokenWithXP {
|
||||
final PangeaToken token;
|
||||
late List<ActivityTypeEnum> targetTypes;
|
||||
|
||||
TokenWithXP({
|
||||
required this.token,
|
||||
}) {
|
||||
targetTypes = [];
|
||||
}
|
||||
|
||||
List<ConstructIdentifier> get _constructIDs {
|
||||
final List<ConstructIdentifier> ids = [];
|
||||
ids.add(
|
||||
ConstructIdentifier(
|
||||
lemma: token.lemma.text,
|
||||
type: ConstructTypeEnum.vocab,
|
||||
category: token.pos,
|
||||
),
|
||||
);
|
||||
for (final morph in token.morph.entries) {
|
||||
ids.add(
|
||||
ConstructIdentifier(
|
||||
lemma: morph.value,
|
||||
type: ConstructTypeEnum.morph,
|
||||
category: morph.key,
|
||||
),
|
||||
);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
List<ConstructUses> get constructs => _constructIDs
|
||||
.map(
|
||||
(id) =>
|
||||
MatrixState.pangeaController.getAnalytics.constructListModel
|
||||
.getConstructUses(id) ??
|
||||
ConstructUses(
|
||||
lemma: id.lemma,
|
||||
constructType: id.type,
|
||||
category: id.category,
|
||||
uses: [],
|
||||
),
|
||||
)
|
||||
.toList();
|
||||
|
||||
factory TokenWithXP.fromJson(Map<String, dynamic> json) {
|
||||
return TokenWithXP(
|
||||
token: PangeaToken.fromJson(json['token'] as Map<String, dynamic>),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'token': token.toJson(),
|
||||
'constructs_with_xp': constructs.map((e) => e.toJson()).toList(),
|
||||
'target_types': targetTypes.map((e) => e.string).toList(),
|
||||
};
|
||||
}
|
||||
|
||||
bool eligibleForActivity(ActivityTypeEnum a) {
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return token.isContentWord;
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return token.canBeHeard;
|
||||
}
|
||||
}
|
||||
|
||||
bool didActivity(ActivityTypeEnum a) {
|
||||
switch (a) {
|
||||
case ActivityTypeEnum.wordMeaning:
|
||||
return vocabConstruct.uses
|
||||
.map((u) => u.useType)
|
||||
.any((u) => a.associatedUseTypes.contains(u));
|
||||
case ActivityTypeEnum.wordFocusListening:
|
||||
return vocabConstruct.uses
|
||||
// TODO - double-check that form is going to be available here
|
||||
// .where((u) =>
|
||||
// u.form?.toLowerCase() == token.text.content.toLowerCase(),)
|
||||
.map((u) => u.useType)
|
||||
.any((u) => a.associatedUseTypes.contains(u));
|
||||
case ActivityTypeEnum.hiddenWordListening:
|
||||
return vocabConstruct.uses
|
||||
// TODO - double-check that form is going to be available here
|
||||
// .where((u) =>
|
||||
// u.form?.toLowerCase() == token.text.content.toLowerCase(),)
|
||||
.map((u) => u.useType)
|
||||
.any((u) => a.associatedUseTypes.contains(u));
|
||||
}
|
||||
}
|
||||
|
||||
ConstructUses get vocabConstruct {
|
||||
final vocab = constructs.firstWhereOrNull(
|
||||
(element) => element.id.type == ConstructTypeEnum.vocab,
|
||||
);
|
||||
if (vocab == null) {
|
||||
return ConstructUses(
|
||||
lemma: token.lemma.text,
|
||||
constructType: ConstructTypeEnum.vocab,
|
||||
category: token.pos,
|
||||
uses: [],
|
||||
);
|
||||
}
|
||||
return vocab;
|
||||
}
|
||||
|
||||
int get xp {
|
||||
return constructs.fold<int>(
|
||||
0,
|
||||
(previousValue, element) => previousValue + element.points,
|
||||
);
|
||||
}
|
||||
|
||||
///
|
||||
DateTime? get lastUsed => constructs.fold<DateTime?>(
|
||||
null,
|
||||
(previousValue, element) {
|
||||
if (previousValue == null) return element.lastUsed;
|
||||
if (element.lastUsed == null) return previousValue;
|
||||
return element.lastUsed!.isAfter(previousValue)
|
||||
? element.lastUsed
|
||||
: previousValue;
|
||||
},
|
||||
);
|
||||
|
||||
/// daysSinceLastUse
|
||||
int get daysSinceLastUse {
|
||||
if (lastUsed == null) return 1000;
|
||||
return DateTime.now().difference(lastUsed!).inDays;
|
||||
}
|
||||
|
||||
//override operator == and hashCode
|
||||
// check that the list of constructs are the same
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is TokenWithXP &&
|
||||
const ListEquality().equals(other.constructs, constructs);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return const ListEquality().hash(constructs);
|
||||
}
|
||||
}
|
||||
|
||||
class ExistingActivityMetaData {
|
||||
final String activityEventId;
|
||||
final List<ConstructIdentifier> tgtConstructs;
|
||||
final ActivityTypeEnum activityType;
|
||||
|
||||
ExistingActivityMetaData({
|
||||
required this.activityEventId,
|
||||
required this.tgtConstructs,
|
||||
required this.activityType,
|
||||
});
|
||||
|
||||
factory ExistingActivityMetaData.fromJson(Map<String, dynamic> json) {
|
||||
return ExistingActivityMetaData(
|
||||
activityEventId: json['activity_event_id'] as String,
|
||||
tgtConstructs: ((json['tgt_constructs'] ?? json['target_constructs'])
|
||||
as List)
|
||||
.map((e) => ConstructIdentifier.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
activityType: ActivityTypeEnum.values.firstWhere(
|
||||
(element) =>
|
||||
element.string == json['activity_type'] as String ||
|
||||
element.string.split('.').last == json['activity_type'] as String,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'activity_event_id': activityEventId,
|
||||
'target_constructs': tgtConstructs.map((e) => e.toJson()).toList(),
|
||||
'activity_type': activityType.string,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// includes feedback text and the bad activity model
|
||||
class ActivityQualityFeedback {
|
||||
|
|
@ -235,102 +49,79 @@ class MessageActivityRequest {
|
|||
final String userL2;
|
||||
|
||||
final String messageText;
|
||||
final List<PangeaToken> messageTokens;
|
||||
|
||||
final List<PangeaToken> targetTokens;
|
||||
final ActivityTypeEnum targetType;
|
||||
|
||||
final ActivityQualityFeedback? activityQualityFeedback;
|
||||
|
||||
/// tokens with their associated constructs and xp
|
||||
final List<TokenWithXP> tokensWithXP;
|
||||
|
||||
/// make the server aware of existing activities for potential reuse
|
||||
final List<ExistingActivityMetaData> existingActivities;
|
||||
|
||||
final String messageId;
|
||||
|
||||
final List<ActivityTypeEnum> clientCompatibleActivities;
|
||||
|
||||
final ActivityTypeEnum clientTypeRequest;
|
||||
|
||||
final TokenWithXP clientTokenRequest;
|
||||
|
||||
MessageActivityRequest({
|
||||
required this.userL1,
|
||||
required this.userL2,
|
||||
required this.messageText,
|
||||
required this.tokensWithXP,
|
||||
required this.messageId,
|
||||
required this.existingActivities,
|
||||
required this.messageTokens,
|
||||
required this.activityQualityFeedback,
|
||||
required this.clientCompatibleActivities,
|
||||
required this.clientTokenRequest,
|
||||
required this.clientTypeRequest,
|
||||
});
|
||||
required this.targetTokens,
|
||||
required this.targetType,
|
||||
}) {
|
||||
if (targetTokens.isEmpty) {
|
||||
throw Exception('Target tokens must not be empty');
|
||||
}
|
||||
if ([ActivityTypeEnum.wordFocusListening, ActivityTypeEnum.wordMeaning]
|
||||
.contains(targetType) &&
|
||||
targetTokens.length > 1) {
|
||||
throw Exception(
|
||||
'Target tokens must be a single token for this activity type',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'user_l1': userL1,
|
||||
'user_l2': userL2,
|
||||
'message_text': messageText,
|
||||
'tokens_with_xp': tokensWithXP.map((e) => e.toJson()).toList(),
|
||||
'message_id': messageId,
|
||||
'existing_activities': existingActivities.map((e) => e.toJson()).toList(),
|
||||
'message_tokens': messageTokens.map((e) => e.toJson()).toList(),
|
||||
'activity_quality_feedback': activityQualityFeedback?.toJson(),
|
||||
'iso_8601_time_of_req': DateTime.now().toIso8601String(),
|
||||
// this is a list of activity types that the client can handle
|
||||
// the server will only return activities of these types
|
||||
// this for backwards compatibility with old clients
|
||||
'client_version_compatible_activity_types':
|
||||
clientCompatibleActivities.map((e) => e.string).toList(),
|
||||
'client_type_request': clientTypeRequest.string,
|
||||
'client_token_request': clientTokenRequest.toJson(),
|
||||
'target_tokens': targetTokens.map((e) => e.toJson()).toList(),
|
||||
'target_type': targetType.string,
|
||||
};
|
||||
}
|
||||
|
||||
// equals accounts for message_id and last_used of each token
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is MessageActivityRequest &&
|
||||
other.messageId == messageId &&
|
||||
const ListEquality().equals(other.tokensWithXP, tokensWithXP);
|
||||
other.messageText == messageText &&
|
||||
other.targetType == targetType &&
|
||||
other.activityQualityFeedback?.feedbackText ==
|
||||
activityQualityFeedback?.feedbackText &&
|
||||
const ListEquality().equals(other.targetTokens, targetTokens);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return messageId.hashCode ^
|
||||
const ListEquality().hash(tokensWithXP) ^
|
||||
activityQualityFeedback.hashCode;
|
||||
return messageText.hashCode ^
|
||||
targetType.hashCode ^
|
||||
activityQualityFeedback.hashCode ^
|
||||
targetTokens.hashCode;
|
||||
}
|
||||
}
|
||||
|
||||
class MessageActivityResponse {
|
||||
final PracticeActivityModel? activity;
|
||||
final bool finished;
|
||||
final String? existingActivityEventId;
|
||||
final PracticeActivityModel activity;
|
||||
|
||||
MessageActivityResponse({
|
||||
required this.activity,
|
||||
required this.finished,
|
||||
required this.existingActivityEventId,
|
||||
});
|
||||
|
||||
factory MessageActivityResponse.fromJson(Map<String, dynamic> json) {
|
||||
return MessageActivityResponse(
|
||||
activity: json['activity'] != null
|
||||
? PracticeActivityModel.fromJson(
|
||||
json['activity'] as Map<String, dynamic>,
|
||||
)
|
||||
: null,
|
||||
finished: json['finished'] as bool,
|
||||
existingActivityEventId: json['existing_activity_event_id'] as String?,
|
||||
activity: PracticeActivityModel.fromJson(
|
||||
json['activity'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'activity': activity?.toJson(),
|
||||
'finished': finished,
|
||||
'existing_activity_event_id': existingActivityEventId,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,4 +58,20 @@ class ActivityContent {
|
|||
'span_display_details': spanDisplayDetails?.toJson(),
|
||||
};
|
||||
}
|
||||
|
||||
// ovveride operator == and hashCode
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is ActivityContent &&
|
||||
other.question == question &&
|
||||
other.choices == choices &&
|
||||
other.answer == answer;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return question.hashCode ^ choices.hashCode ^ answer.hashCode;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -189,14 +189,12 @@ class PracticeActivityRequest {
|
|||
class PracticeActivityModel {
|
||||
final List<ConstructIdentifier> tgtConstructs;
|
||||
final String langCode;
|
||||
final String msgId;
|
||||
final ActivityTypeEnum activityType;
|
||||
final ActivityContent content;
|
||||
|
||||
PracticeActivityModel({
|
||||
required this.tgtConstructs,
|
||||
required this.langCode,
|
||||
required this.msgId,
|
||||
required this.activityType,
|
||||
required this.content,
|
||||
});
|
||||
|
|
@ -223,7 +221,6 @@ class PracticeActivityModel {
|
|||
.map((e) => ConstructIdentifier.fromJson(e as Map<String, dynamic>))
|
||||
.toList(),
|
||||
langCode: json['lang_code'] as String,
|
||||
msgId: json['msg_id'] as String,
|
||||
activityType:
|
||||
ActivityTypeEnum.wordMeaning.fromString(json['activity_type']),
|
||||
content: ActivityContent.fromJson(contentMap),
|
||||
|
|
@ -237,7 +234,6 @@ class PracticeActivityModel {
|
|||
return {
|
||||
'target_constructs': tgtConstructs.map((e) => e.toJson()).toList(),
|
||||
'lang_code': langCode,
|
||||
'msg_id': msgId,
|
||||
'activity_type': activityType.string,
|
||||
'content': content.toJson(),
|
||||
};
|
||||
|
|
@ -251,7 +247,6 @@ class PracticeActivityModel {
|
|||
return other is PracticeActivityModel &&
|
||||
const ListEquality().equals(other.tgtConstructs, tgtConstructs) &&
|
||||
other.langCode == langCode &&
|
||||
other.msgId == msgId &&
|
||||
other.activityType == activityType &&
|
||||
other.content == content;
|
||||
}
|
||||
|
|
@ -260,7 +255,6 @@ class PracticeActivityModel {
|
|||
int get hashCode {
|
||||
return const ListEquality().hash(tgtConstructs) ^
|
||||
langCode.hashCode ^
|
||||
msgId.hashCode ^
|
||||
activityType.hashCode ^
|
||||
content.hashCode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -59,7 +59,7 @@ class PApiUrls {
|
|||
static String speechToText = "${PApiUrls.choreoEndpoint}/speech_to_text";
|
||||
|
||||
static String messageActivityGeneration =
|
||||
"${PApiUrls.choreoEndpoint}/practice/message";
|
||||
"${PApiUrls.choreoEndpoint}/practice";
|
||||
|
||||
///-------------------------------- revenue cat --------------------------
|
||||
|
||||
|
|
|
|||
|
|
@ -6,13 +6,14 @@ import 'package:fluffychat/config/setting_keys.dart';
|
|||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/pages/chat/chat.dart';
|
||||
import 'package:fluffychat/pages/chat/events/message_reactions.dart';
|
||||
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/enum/activity_display_instructions_enum.dart';
|
||||
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/enum/message_mode_enum.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/widgets/chat/message_token_text.dart';
|
||||
import 'package:fluffychat/pangea/widgets/chat/message_toolbar.dart';
|
||||
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_buttons.dart';
|
||||
import 'package:fluffychat/pangea/widgets/chat/overlay_footer.dart';
|
||||
|
|
@ -28,24 +29,25 @@ import 'package:matrix/matrix.dart';
|
|||
|
||||
class MessageSelectionOverlay extends StatefulWidget {
|
||||
final ChatController chatController;
|
||||
late final Event _event;
|
||||
late final Event? _nextEvent;
|
||||
late final Event? _prevEvent;
|
||||
late final PangeaMessageEvent _pangeaMessageEvent;
|
||||
final Event _event;
|
||||
final Event? _nextEvent;
|
||||
final Event? _prevEvent;
|
||||
final PangeaMessageEvent _pangeaMessageEvent;
|
||||
final PangeaToken? _selectedTokenOnInitialization;
|
||||
|
||||
MessageSelectionOverlay({
|
||||
const MessageSelectionOverlay({
|
||||
required this.chatController,
|
||||
required Event event,
|
||||
required PangeaMessageEvent pangeaMessageEvent,
|
||||
required PangeaToken? selectedTokenOnInitialization,
|
||||
required Event? nextEvent,
|
||||
required Event? prevEvent,
|
||||
super.key,
|
||||
}) {
|
||||
_pangeaMessageEvent = pangeaMessageEvent;
|
||||
_nextEvent = nextEvent;
|
||||
_prevEvent = prevEvent;
|
||||
_event = event;
|
||||
}
|
||||
}) : _selectedTokenOnInitialization = selectedTokenOnInitialization,
|
||||
_pangeaMessageEvent = pangeaMessageEvent,
|
||||
_nextEvent = nextEvent,
|
||||
_prevEvent = prevEvent,
|
||||
_event = event;
|
||||
|
||||
@override
|
||||
MessageOverlayController createState() => MessageOverlayController();
|
||||
|
|
@ -76,6 +78,17 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
|
||||
bool get showToolbarButtons => !widget._pangeaMessageEvent.isAudioMessage;
|
||||
|
||||
PangeaToken? get selectedTargetTokenForWordMeaning =>
|
||||
widget._selectedTokenOnInitialization != null &&
|
||||
!(messageAnalyticsEntry?.isTokenInHiddenWordActivity(
|
||||
widget._selectedTokenOnInitialization!,
|
||||
) ??
|
||||
false) &&
|
||||
widget._selectedTokenOnInitialization!
|
||||
.shouldDoActivity(ActivityTypeEnum.wordMeaning)
|
||||
? widget._selectedTokenOnInitialization
|
||||
: null;
|
||||
|
||||
List<PangeaToken>? tokens;
|
||||
|
||||
@override
|
||||
|
|
@ -113,30 +126,24 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
).listen((_) => setState(() {}));
|
||||
|
||||
tts.setupTTS();
|
||||
setInitialToolbarMode();
|
||||
|
||||
_setInitialToolbarModeAndSelectedSpan();
|
||||
}
|
||||
|
||||
MessageTokenText get messageTokenText => MessageTokenText(
|
||||
ownMessage: pangeaMessageEvent.ownMessage,
|
||||
fullText: pangeaMessageEvent.messageDisplayText,
|
||||
tokensWithDisplay: tokens
|
||||
?.map(
|
||||
(token) => TokenWithDisplayInstructions(
|
||||
token: token,
|
||||
highlight: isTokenSelected(token),
|
||||
//NOTE: we actually do want the controller to be aware of which
|
||||
// tokens are currently being involved in activities and adjust here
|
||||
hideContent: false,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
onClick: onClickOverlayMessageToken,
|
||||
);
|
||||
MessageAnalyticsEntry? get messageAnalyticsEntry => tokens != null
|
||||
? MatrixState.pangeaController.getAnalytics.perMessage.get(
|
||||
tokens!,
|
||||
// this logic should be in the controller
|
||||
!pangeaMessageEvent.ownMessage &&
|
||||
pangeaMessageEvent.messageDisplayRepresentation?.tokens != null,
|
||||
)
|
||||
: null;
|
||||
|
||||
Future<void> _getTokens() async {
|
||||
tokens = pangeaMessageEvent.originalSent?.tokens;
|
||||
|
||||
if (pangeaMessageEvent.originalSent != null && tokens == null) {
|
||||
debugPrint("fetching tokens");
|
||||
pangeaMessageEvent.originalSent!
|
||||
.tokensGlobal(
|
||||
pangeaMessageEvent.senderId,
|
||||
|
|
@ -144,7 +151,8 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
)
|
||||
.then((tokens) {
|
||||
// this isn't currently working because originalSent's _event is null
|
||||
setState(() => this.tokens = tokens);
|
||||
this.tokens = tokens;
|
||||
_setInitialToolbarModeAndSelectedSpan();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -201,31 +209,38 @@ class MessageOverlayController extends State<MessageSelectionOverlay>
|
|||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> setInitialToolbarMode() async {
|
||||
Future<void> _setInitialToolbarModeAndSelectedSpan() async {
|
||||
debugPrint(
|
||||
"setting initial toolbar mode and selected span with tokens $tokens",
|
||||
);
|
||||
|
||||
if (widget._pangeaMessageEvent.isAudioMessage) {
|
||||
toolbarMode = MessageMode.speechToText;
|
||||
return;
|
||||
}
|
||||
// if (!messageInUserL2) {
|
||||
// activitiesLeftToComplete = 0;
|
||||
// toolbarMode = MessageMode.nullMode;
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (activitiesLeftToComplete > 0) {
|
||||
toolbarMode = MessageMode.practiceActivity;
|
||||
return;
|
||||
return setState(() => toolbarMode = MessageMode.practiceActivity);
|
||||
}
|
||||
|
||||
// we're only going to do activities if we have tokens for the message
|
||||
if (tokens != null) {
|
||||
// if the user selects a span on initialization, then we want to give
|
||||
// them a practice activity on that word
|
||||
if (selectedTargetTokenForWordMeaning != null) {
|
||||
_selectedSpan = selectedTargetTokenForWordMeaning?.text;
|
||||
return setState(() => toolbarMode = MessageMode.practiceActivity);
|
||||
}
|
||||
|
||||
if (activitiesLeftToComplete > 0) {
|
||||
return setState(() => toolbarMode = MessageMode.practiceActivity);
|
||||
}
|
||||
}
|
||||
|
||||
// Note: this setting is now hidden so this will always be false
|
||||
// leaving this here in case we want to bring it back
|
||||
if (MatrixState.pangeaController.userController.profile.userSettings
|
||||
.autoPlayMessages) {
|
||||
toolbarMode = MessageMode.textToSpeech;
|
||||
return;
|
||||
return setState(() => toolbarMode = MessageMode.textToSpeech);
|
||||
}
|
||||
|
||||
toolbarMode = MessageMode.translation;
|
||||
|
||||
setState(() {});
|
||||
setState(() => toolbarMode = MessageMode.translation);
|
||||
}
|
||||
|
||||
updateToolbarMode(MessageMode mode) {
|
||||
|
|
|
|||
|
|
@ -1,82 +1,101 @@
|
|||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Question - does this need to be stateful or does this work?
|
||||
/// Need to test.
|
||||
class MessageTokenText extends StatelessWidget {
|
||||
final PangeaController pangeaController = MatrixState.pangeaController;
|
||||
final PangeaMessageEvent _pangeaMessageEvent;
|
||||
|
||||
final bool ownMessage;
|
||||
final List<PangeaToken>? _tokens;
|
||||
|
||||
/// this must match the tokens or we've got problems
|
||||
final String fullText;
|
||||
final TextStyle _style;
|
||||
|
||||
/// this must match the fullText or we've got problems
|
||||
final List<TokenWithDisplayInstructions>? tokensWithDisplay;
|
||||
final void Function(PangeaToken)? onClick;
|
||||
final bool Function(PangeaToken)? _isSelected;
|
||||
final void Function(PangeaToken)? _onClick;
|
||||
|
||||
MessageTokenText({
|
||||
const MessageTokenText({
|
||||
super.key,
|
||||
required this.ownMessage,
|
||||
required this.fullText,
|
||||
required this.tokensWithDisplay,
|
||||
required this.onClick,
|
||||
});
|
||||
required PangeaMessageEvent pangeaMessageEvent,
|
||||
required List<PangeaToken>? tokens,
|
||||
required TextStyle style,
|
||||
required void Function(PangeaToken)? onClick,
|
||||
bool Function(PangeaToken)? isSelected,
|
||||
}) : _onClick = onClick,
|
||||
_isSelected = isSelected,
|
||||
_style = style,
|
||||
_tokens = tokens,
|
||||
_pangeaMessageEvent = pangeaMessageEvent;
|
||||
|
||||
MessageAnalyticsEntry? get messageAnalyticsEntry => _tokens != null
|
||||
? MatrixState.pangeaController.getAnalytics.perMessage.get(
|
||||
_tokens!,
|
||||
// this logic should be in the controller
|
||||
!_pangeaMessageEvent.ownMessage &&
|
||||
_pangeaMessageEvent.messageDisplayRepresentation?.tokens != null,
|
||||
)
|
||||
: null;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final style = TextStyle(
|
||||
color: ownMessage
|
||||
? Theme.of(context).colorScheme.onPrimary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
height: 1.3,
|
||||
fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor,
|
||||
);
|
||||
|
||||
if (tokensWithDisplay == null || tokensWithDisplay!.isEmpty) {
|
||||
if (_tokens == null) {
|
||||
return Text(
|
||||
fullText,
|
||||
style: style,
|
||||
_pangeaMessageEvent.messageDisplayText,
|
||||
style: _style,
|
||||
);
|
||||
}
|
||||
|
||||
// Convert the entire message into a list of characters
|
||||
final Characters messageCharacters = fullText.characters;
|
||||
final Characters messageCharacters =
|
||||
_pangeaMessageEvent.messageDisplayText.characters;
|
||||
|
||||
// When building token positions, use grapheme cluster indices
|
||||
final List<TokenPosition> tokenPositions = [];
|
||||
int globalIndex = 0;
|
||||
|
||||
for (int i = 0; i < tokensWithDisplay!.length; i++) {
|
||||
final tokenWithDisplay = tokensWithDisplay![i];
|
||||
final start = tokenWithDisplay.token.start;
|
||||
final end = tokenWithDisplay.token.end;
|
||||
for (final token
|
||||
in _pangeaMessageEvent.messageDisplayRepresentation!.tokens!) {
|
||||
final start = token.start;
|
||||
final end = token.end;
|
||||
|
||||
// Calculate the number of grapheme clusters up to the start and end positions
|
||||
final int startIndex = messageCharacters.take(start).length;
|
||||
final int endIndex = messageCharacters.take(end).length;
|
||||
|
||||
final hideContent =
|
||||
messageAnalyticsEntry?.isTokenInHiddenWordActivity(token) ?? false;
|
||||
|
||||
if (globalIndex < startIndex) {
|
||||
tokenPositions.add(TokenPosition(start: globalIndex, end: startIndex));
|
||||
tokenPositions.add(
|
||||
TokenPosition(
|
||||
start: globalIndex,
|
||||
end: startIndex,
|
||||
hideContent: false,
|
||||
highlight: _isSelected?.call(token) ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
tokenPositions.add(
|
||||
TokenPosition(
|
||||
start: startIndex,
|
||||
end: endIndex,
|
||||
tokenIndex: i,
|
||||
token: tokenWithDisplay,
|
||||
token: token,
|
||||
hideContent: hideContent,
|
||||
highlight: (_isSelected?.call(token) ?? false) && !hideContent,
|
||||
),
|
||||
);
|
||||
globalIndex = endIndex;
|
||||
}
|
||||
|
||||
//TODO - take out of build function of every message
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
children: tokenPositions.map((tokenPosition) {
|
||||
children:
|
||||
tokenPositions.mapIndexed((int i, TokenPosition tokenPosition) {
|
||||
final substring = messageCharacters
|
||||
.skip(tokenPosition.start)
|
||||
.take(tokenPosition.end - tokenPosition.start)
|
||||
|
|
@ -85,13 +104,15 @@ class MessageTokenText extends StatelessWidget {
|
|||
if (tokenPosition.token != null) {
|
||||
return TextSpan(
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => onClick != null
|
||||
? onClick!(tokenPosition.token!.token)
|
||||
..onTap = () => _onClick != null && tokenPosition.token != null
|
||||
? _onClick!(tokenPosition.token!)
|
||||
: null,
|
||||
text: !tokenPosition.token!.hideContent ? substring : '_____',
|
||||
style: style.merge(
|
||||
text: !tokenPosition.hideContent
|
||||
? substring
|
||||
: '_' * substring.length,
|
||||
style: _style.merge(
|
||||
TextStyle(
|
||||
backgroundColor: tokenPosition.token!.highlight
|
||||
backgroundColor: tokenPosition.highlight
|
||||
? Theme.of(context).brightness == Brightness.light
|
||||
? Colors.black.withOpacity(0.4)
|
||||
: Colors.white.withOpacity(0.4)
|
||||
|
|
@ -101,8 +122,12 @@ class MessageTokenText extends StatelessWidget {
|
|||
);
|
||||
} else {
|
||||
return TextSpan(
|
||||
text: substring,
|
||||
style: style,
|
||||
text: (i > 0 || i < tokenPositions.length - 1) &&
|
||||
tokenPositions[i + 1].hideContent &&
|
||||
tokenPositions[i - 1].hideContent
|
||||
? '_' * substring.length
|
||||
: substring,
|
||||
style: _style,
|
||||
);
|
||||
}
|
||||
}).toList(),
|
||||
|
|
@ -111,28 +136,18 @@ class MessageTokenText extends StatelessWidget {
|
|||
}
|
||||
}
|
||||
|
||||
class TokenWithDisplayInstructions {
|
||||
final PangeaToken token;
|
||||
final bool highlight;
|
||||
final bool hideContent;
|
||||
|
||||
TokenWithDisplayInstructions({
|
||||
required this.token,
|
||||
required this.highlight,
|
||||
required this.hideContent,
|
||||
});
|
||||
}
|
||||
|
||||
class TokenPosition {
|
||||
final int start;
|
||||
final int end;
|
||||
final TokenWithDisplayInstructions? token;
|
||||
final int tokenIndex;
|
||||
final bool highlight;
|
||||
final bool hideContent;
|
||||
final PangeaToken? token;
|
||||
|
||||
const TokenPosition({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.hideContent,
|
||||
required this.highlight,
|
||||
this.token,
|
||||
this.tokenIndex = -1,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,128 +0,0 @@
|
|||
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
|
||||
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Question - does this need to be stateful or does this work?
|
||||
/// Need to test.
|
||||
class MessageTokenText extends StatelessWidget {
|
||||
final PangeaController pangeaController = MatrixState.pangeaController;
|
||||
|
||||
final MessageAnalyticsEntry messageAnalyticsEntry;
|
||||
|
||||
final TextStyle style;
|
||||
|
||||
final bool Function(PangeaToken)? isSelected;
|
||||
final void Function(PangeaToken)? onClick;
|
||||
bool get ownMessage => messageAnalyticsEntry.pmEvent.ownMessage;
|
||||
|
||||
MessageTokenText({
|
||||
super.key,
|
||||
required this.messageAnalyticsEntry,
|
||||
required this.style,
|
||||
required this.onClick,
|
||||
this.isSelected,
|
||||
});
|
||||
|
||||
PangeaMessageEvent get pangeaMessageEvent => messageAnalyticsEntry.pmEvent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Convert the entire message into a list of characters
|
||||
final Characters messageCharacters =
|
||||
pangeaMessageEvent.messageDisplayText.characters;
|
||||
|
||||
// When building token positions, use grapheme cluster indices
|
||||
final List<TokenPosition> tokenPositions = [];
|
||||
int globalIndex = 0;
|
||||
|
||||
for (final token in messageAnalyticsEntry.tokensWithXp) {
|
||||
final start = token.token.start;
|
||||
final end = token.token.end;
|
||||
|
||||
// Calculate the number of grapheme clusters up to the start and end positions
|
||||
final int startIndex = messageCharacters.take(start).length;
|
||||
final int endIndex = messageCharacters.take(end).length;
|
||||
|
||||
final hideContent =
|
||||
token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening);
|
||||
|
||||
if (globalIndex < startIndex) {
|
||||
tokenPositions.add(
|
||||
TokenPosition(
|
||||
start: globalIndex,
|
||||
end: startIndex,
|
||||
hideContent: false,
|
||||
highlight: isSelected?.call(token.token) ?? false,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
tokenPositions.add(
|
||||
TokenPosition(
|
||||
start: startIndex,
|
||||
end: endIndex,
|
||||
token: token.token,
|
||||
hideContent: hideContent,
|
||||
highlight: (isSelected?.call(token.token) ?? false) && !hideContent,
|
||||
),
|
||||
);
|
||||
globalIndex = endIndex;
|
||||
}
|
||||
|
||||
return RichText(
|
||||
text: TextSpan(
|
||||
children: tokenPositions.map((tokenPosition) {
|
||||
final substring = messageCharacters
|
||||
.skip(tokenPosition.start)
|
||||
.take(tokenPosition.end - tokenPosition.start)
|
||||
.toString();
|
||||
|
||||
if (tokenPosition.token != null) {
|
||||
return TextSpan(
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => onClick != null && tokenPosition.token != null
|
||||
? onClick!(tokenPosition.token!)
|
||||
: null,
|
||||
text: !tokenPosition.hideContent ? substring : '_____',
|
||||
style: style.merge(
|
||||
TextStyle(
|
||||
backgroundColor: tokenPosition.highlight
|
||||
? Theme.of(context).brightness == Brightness.light
|
||||
? Colors.black.withOpacity(0.4)
|
||||
: Colors.white.withOpacity(0.4)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return TextSpan(
|
||||
text: substring,
|
||||
style: style,
|
||||
);
|
||||
}
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class TokenPosition {
|
||||
final int start;
|
||||
final int end;
|
||||
final bool highlight;
|
||||
final bool hideContent;
|
||||
final PangeaToken? token;
|
||||
|
||||
const TokenPosition({
|
||||
required this.start,
|
||||
required this.end,
|
||||
required this.hideContent,
|
||||
required this.highlight,
|
||||
this.token,
|
||||
});
|
||||
}
|
||||
|
|
@ -72,7 +72,8 @@ class MessageToolbar extends StatelessWidget {
|
|||
return FutureBuilder(
|
||||
//TODO - convert this to synchronous if possible
|
||||
future: Future.value(
|
||||
pangeaMessageEvent.messageDisplayRepresentation?.tokens),
|
||||
pangeaMessageEvent.messageDisplayRepresentation?.tokens,
|
||||
),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const ToolbarContentLoadingIndicator();
|
||||
|
|
@ -127,6 +128,8 @@ class MessageToolbar extends StatelessWidget {
|
|||
);
|
||||
}
|
||||
return PracticeActivityCard(
|
||||
selectedTargetTokenForWordMeaning:
|
||||
overLayController.selectedTargetTokenForWordMeaning,
|
||||
pangeaMessageEvent: pangeaMessageEvent,
|
||||
overlayController: overLayController,
|
||||
ttsController: ttsController,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
|
||||
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
|
||||
import 'package:fluffychat/pangea/controllers/practice_activity_generation_controller.dart';
|
||||
|
|
@ -9,6 +10,7 @@ import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
|
|||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_event.dart';
|
||||
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_model.dart';
|
||||
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
|
||||
|
|
@ -31,12 +33,14 @@ class PracticeActivityCard extends StatefulWidget {
|
|||
final PangeaMessageEvent pangeaMessageEvent;
|
||||
final MessageOverlayController overlayController;
|
||||
final TtsController ttsController;
|
||||
final PangeaToken? selectedTargetTokenForWordMeaning;
|
||||
|
||||
const PracticeActivityCard({
|
||||
super.key,
|
||||
required this.pangeaMessageEvent,
|
||||
required this.overlayController,
|
||||
required this.ttsController,
|
||||
required this.selectedTargetTokenForWordMeaning,
|
||||
});
|
||||
|
||||
@override
|
||||
|
|
@ -53,31 +57,6 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
|
|||
List<PracticeActivityEvent> get practiceActivities =>
|
||||
widget.pangeaMessageEvent.practiceActivities;
|
||||
|
||||
MessageAnalyticsEntry? get messageAnalyticsEntry =>
|
||||
MatrixState.pangeaController.getAnalytics.perMessage
|
||||
.get(widget.pangeaMessageEvent, false);
|
||||
|
||||
PracticeActivityEvent? get existingActivityMatchingNeeds {
|
||||
if (messageAnalyticsEntry?.nextActivityToken == null) {
|
||||
debugger(when: kDebugMode);
|
||||
return null;
|
||||
}
|
||||
|
||||
for (final existingActivity in practiceActivities) {
|
||||
for (final c in messageAnalyticsEntry!.nextActivityToken!.constructs) {
|
||||
if (existingActivity.practiceActivity.tgtConstructs
|
||||
.any((tc) => tc == c.id) &&
|
||||
existingActivity.practiceActivity.activityType ==
|
||||
messageAnalyticsEntry!.nextActivityType) {
|
||||
debugPrint('found existing activity');
|
||||
return existingActivity;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// Used to show an animation when the user completes an activity
|
||||
// while simultaneously fetching a new activity and not showing the loading spinner
|
||||
// until the appropriate time has passed to 'savor the joy'
|
||||
|
|
@ -118,72 +97,63 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
|
|||
/// If not, get a new activity from the server.
|
||||
Future<void> initialize() async {
|
||||
_setPracticeActivity(
|
||||
await _fetchActivity(),
|
||||
await _fetchActivity(
|
||||
selectedTargetTokenForWordMeaning:
|
||||
widget.selectedTargetTokenForWordMeaning,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<PracticeActivityModel?> _fetchActivity([
|
||||
Future<PracticeActivityModel?> _fetchActivity({
|
||||
ActivityQualityFeedback? activityFeedback,
|
||||
]) async {
|
||||
// temporary
|
||||
PangeaToken? selectedTargetTokenForWordMeaning,
|
||||
}) async {
|
||||
// try {
|
||||
debugPrint('Fetching activity');
|
||||
// debugger();
|
||||
_updateFetchingActivity(true);
|
||||
|
||||
// target tokens can be empty if activities have been completed for each
|
||||
// it's set on initialization and then removed when each activity is completed
|
||||
if (!pangeaController.languageController.languagesSet) {
|
||||
if (!mounted ||
|
||||
!pangeaController.languageController.languagesSet ||
|
||||
widget.overlayController.messageAnalyticsEntry == null) {
|
||||
debugger(when: kDebugMode);
|
||||
_updateFetchingActivity(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mounted) {
|
||||
debugger(when: kDebugMode);
|
||||
_updateFetchingActivity(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (widget.pangeaMessageEvent.messageDisplayRepresentation == null) {
|
||||
debugger(when: kDebugMode);
|
||||
_updateFetchingActivity(false);
|
||||
ErrorHandler.logError(
|
||||
e: Exception('No original message found in _fetchNewActivity'),
|
||||
data: {
|
||||
'event': widget.pangeaMessageEvent.event.toJson(),
|
||||
},
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
if (widget.pangeaMessageEvent.messageDisplayRepresentation?.tokens ==
|
||||
null) {
|
||||
debugger(when: kDebugMode);
|
||||
_updateFetchingActivity(false);
|
||||
return null;
|
||||
}
|
||||
// if the user selected a token which is not already in a hidden word activity,
|
||||
// we're going to give them an activity on that token first
|
||||
// otherwise, we're going to give them an activity on the next token in the queue
|
||||
final TargetTokensAndActivityType? nextActivitySpecs =
|
||||
selectedTargetTokenForWordMeaning != null
|
||||
? TargetTokensAndActivityType(
|
||||
tokens: [selectedTargetTokenForWordMeaning],
|
||||
activityType: ActivityTypeEnum.wordMeaning,
|
||||
)
|
||||
: widget.overlayController.messageAnalyticsEntry?.nextActivity;
|
||||
|
||||
// the client is going to be choosing the next activity now
|
||||
// if nothing is set then it must be done with practice
|
||||
if (messageAnalyticsEntry?.nextActivityToken == null ||
|
||||
messageAnalyticsEntry?.nextActivityType == null) {
|
||||
debugger(when: kDebugMode);
|
||||
if (nextActivitySpecs == null) {
|
||||
debugPrint("No next activity set, exiting practice flow");
|
||||
_updateFetchingActivity(false);
|
||||
return null;
|
||||
}
|
||||
|
||||
final existingActivity = existingActivityMatchingNeeds;
|
||||
final existingActivity = practiceActivities.firstWhereOrNull(
|
||||
(activity) =>
|
||||
nextActivitySpecs.matchesActivity(activity.practiceActivity),
|
||||
);
|
||||
|
||||
if (existingActivity != null) {
|
||||
debugPrint('found existing activity');
|
||||
_updateFetchingActivity(false);
|
||||
return existingActivity.practiceActivity;
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
"client requesting activity type: ${messageAnalyticsEntry?.nextActivityType}",
|
||||
);
|
||||
debugPrint(
|
||||
"client requesting token: ${messageAnalyticsEntry?.nextActivityToken?.token.text.content}",
|
||||
"client requesting ${nextActivitySpecs.activityType.string} for ${nextActivitySpecs.tokens.map((t) => t.text).join(' ')}",
|
||||
);
|
||||
|
||||
final PracticeActivityModelResponse? activityResponse =
|
||||
|
|
@ -192,20 +162,10 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
|
|||
userL1: pangeaController.languageController.userL1!.langCode,
|
||||
userL2: pangeaController.languageController.userL2!.langCode,
|
||||
messageText: widget.pangeaMessageEvent.originalSent!.text,
|
||||
tokensWithXP: messageAnalyticsEntry!.tokensWithXp,
|
||||
messageId: widget.pangeaMessageEvent.eventId,
|
||||
existingActivities: practiceActivities
|
||||
.map((activity) => activity.activityRequestMetaData)
|
||||
.toList(),
|
||||
messageTokens: widget.overlayController.tokens!,
|
||||
activityQualityFeedback: activityFeedback,
|
||||
clientCompatibleActivities: widget
|
||||
.ttsController.isLanguageFullySupported
|
||||
? ActivityTypeEnum.values
|
||||
: ActivityTypeEnum.values
|
||||
.where((type) => type != ActivityTypeEnum.wordFocusListening)
|
||||
.toList(),
|
||||
clientTokenRequest: messageAnalyticsEntry!.nextActivityToken!,
|
||||
clientTypeRequest: messageAnalyticsEntry!.nextActivityType!,
|
||||
targetTokens: nextActivitySpecs.tokens,
|
||||
targetType: nextActivitySpecs.activityType,
|
||||
),
|
||||
widget.pangeaMessageEvent,
|
||||
);
|
||||
|
|
@ -269,15 +229,11 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
|
|||
return;
|
||||
}
|
||||
|
||||
// update the target tokens with the new construct uses
|
||||
// NOTE - multiple choice activity is handling adding these to analytics
|
||||
|
||||
// previously we would update the tokens with the constructs
|
||||
// now the tokens themselves calculate their own points using the analytics
|
||||
// we're going to see if this creates performance issues
|
||||
messageAnalyticsEntry?.updateTargetTypesForMessage();
|
||||
widget.overlayController.messageAnalyticsEntry!
|
||||
.onActivityComplete(currentActivity!);
|
||||
|
||||
widget.overlayController.onActivityFinish();
|
||||
|
||||
pangeaController.activityRecordController.completeActivity(
|
||||
widget.pangeaMessageEvent.eventId,
|
||||
);
|
||||
|
|
@ -305,7 +261,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
|
|||
}
|
||||
|
||||
void _onError() {
|
||||
messageAnalyticsEntry?.revealAllTokens();
|
||||
widget.overlayController.messageAnalyticsEntry?.revealAllTokens();
|
||||
_setPracticeActivity(null);
|
||||
}
|
||||
|
||||
|
|
@ -333,7 +289,7 @@ class PracticeActivityCardState extends State<PracticeActivityCard> {
|
|||
}
|
||||
|
||||
_fetchActivity(
|
||||
ActivityQualityFeedback(
|
||||
activityFeedback: ActivityQualityFeedback(
|
||||
feedbackText: feedback,
|
||||
badActivity: currentActivity!,
|
||||
),
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue