You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
fluffychat/lib/pangea/practice_activities/practice_record.dart

347 lines
11 KiB
Dart

// record the options that the user selected
// note that this is not the same as the correct answer
// the user might have selected multiple options before
// finding the answer
import 'dart:developer';
import 'package:flutter/foundation.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/common/utils/error_handler.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_activity_model.dart';
import 'package:fluffychat/pangea/practice_activities/practice_record_repo.dart';
import 'package:fluffychat/pangea/practice_activities/practice_target.dart';
class PracticeRecord {
late DateTime createdAt;
late List<ActivityRecordResponse> responses;
PracticeRecord({
List<ActivityRecordResponse>? responses,
DateTime? timestamp,
}) {
createdAt = timestamp ?? DateTime.now();
if (responses == null) {
this.responses = List<ActivityRecordResponse>.empty(growable: true);
} else {
this.responses = responses;
}
}
factory PracticeRecord.fromJson(
Map<String, dynamic> json,
) {
return PracticeRecord(
responses: (json['responses'] as List)
.map(
(e) => ActivityRecordResponse.fromJson(e as Map<String, dynamic>),
)
.toList(),
timestamp: json['createdAt'] != null
? DateTime.parse(json['createdAt'] as String)
: null,
);
}
Map<String, dynamic> toJson() {
return {
'responses': responses.map((e) => e.toJson()).toList(),
'createdAt': createdAt.toIso8601String(),
};
}
int get completeResponses =>
responses.where((element) => element.isCorrect).length;
/// get the latest response index according to the response timeStamp
/// sort the responses by timestamp and get the index of the last response
ActivityRecordResponse? get latestResponse {
if (responses.isEmpty) {
return null;
}
responses.sort((a, b) => a.timestamp.compareTo(b.timestamp));
return responses[responses.length - 1];
}
bool hasTextResponse(String text) {
return responses.any((element) => element.text == text);
}
bool alreadyHasMatchResponse(
ConstructIdentifier cId,
String text,
) {
return responses.any(
(element) => element.cId == cId && element.text == text,
);
}
/// [target] needed for saving the record, little funky
/// [cId] identifies the construct in the case of match activities which have multiple
/// [text] is the user's response
/// [audioBytes] is the user's audio response
/// [imageBytes] is the user's image response
/// [score] > 0 means correct, otherwise is incorrect
void addResponse({
required ConstructIdentifier cId,
required PracticeTarget target,
String? text,
Uint8List? audioBytes,
Uint8List? imageBytes,
required double score,
}) {
try {
if (text == null && audioBytes == null && imageBytes == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "No response data provided",
data: {
'cId': cId.toJson(),
'text': text,
'audioBytes': audioBytes,
'imageBytes': imageBytes,
'score': score,
},
);
return;
}
responses.add(
ActivityRecordResponse(
cId: cId,
text: text,
audioBytes: audioBytes,
imageBytes: imageBytes,
timestamp: DateTime.now(),
score: score,
),
);
debugPrint("responses: ${responses.map((r) => r.toJson())}");
PracticeRecordRepo.save(target, this);
} catch (e) {
debugger(when: kDebugMode);
}
}
void clearResponses() {
responses.clear();
}
/// Returns a list of [OneConstructUse] objects representing the uses of the practice activity.
///
/// The [practiceActivity] parameter is the parent event, representing the activity itself.
/// The [metadata] parameter is the metadata for the construct use, used if the record event isn't available.
///
/// The method iterates over the [responses] to get [OneConstructUse] objects for each
List<OneConstructUse> usesForAllResponses(
PracticeActivityModel practiceActivity,
ConstructUseMetaData metadata,
) =>
responses
.toSet()
.expand(
(response) => response.toUses(practiceActivity, metadata),
)
.toList();
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PracticeRecord &&
other.responses.length == responses.length &&
List.generate(
responses.length,
(index) => responses[index] == other.responses[index],
).every((element) => element);
}
@override
int get hashCode => responses.hashCode;
}
class ActivityRecordResponse {
/// the cId of the construct that the user attached their response to
/// ie. in the "I like the dog" if the user erroneously attaches a dog emoji to the word like
/// then the cId is that of 'like
ConstructIdentifier cId;
// the user's response
// has nullable string, nullable audio bytes, nullable image bytes, and timestamp
final String? text;
final Uint8List? audioBytes;
final Uint8List? imageBytes;
final DateTime timestamp;
final double score;
ActivityRecordResponse({
required this.cId,
this.text,
this.audioBytes,
this.imageBytes,
required this.score,
required this.timestamp,
});
bool get isCorrect => score > 0;
//TODO - differentiate into different activity types
ConstructUseTypeEnum useType(ActivityTypeEnum aType) =>
isCorrect ? aType.correctUse : aType.incorrectUse;
// for each target construct create a OneConstructUse object
List<OneConstructUse> toUses(
PracticeActivityModel practiceActivity,
ConstructUseMetaData metadata,
) {
// if the emoji is already set, don't give points
// IMPORTANT: This assumes that scoring is happening before saving of the user's emoji choice.
if (practiceActivity.activityType == ActivityTypeEnum.emoji &&
practiceActivity.targetTokens.first.getEmoji().isNotEmpty) {
return [];
}
if (practiceActivity.targetTokens.isEmpty) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "null targetTokens in practice activity",
data: practiceActivity.toJson(),
);
return [];
}
switch (practiceActivity.activityType) {
case ActivityTypeEnum.emoji:
case ActivityTypeEnum.wordMeaning:
case ActivityTypeEnum.wordFocusListening:
case ActivityTypeEnum.lemmaId:
final token = practiceActivity.targetTokens.first;
final constructUseType = useType(practiceActivity.activityType);
return [
OneConstructUse(
lemma: token.lemma.text,
form: token.text.content,
constructType: ConstructTypeEnum.vocab,
useType: constructUseType,
metadata: metadata,
category: token.pos,
xp: constructUseType.pointValue,
),
];
case ActivityTypeEnum.messageMeaning:
final constructUseType = useType(practiceActivity.activityType);
return practiceActivity.targetTokens
.expand(
(t) => t.allUses(
constructUseType,
metadata,
constructUseType.pointValue,
),
)
.toList();
case ActivityTypeEnum.hiddenWordListening:
final constructUseType = useType(practiceActivity.activityType);
return practiceActivity.targetTokens
.map(
(token) => OneConstructUse(
lemma: token.lemma.text,
form: token.text.content,
constructType: ConstructTypeEnum.vocab,
useType: constructUseType,
metadata: metadata,
category: token.pos,
xp: constructUseType.pointValue,
),
)
.toList();
case ActivityTypeEnum.morphId:
if (practiceActivity.morphFeature == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "null morphFeature in morph activity",
data: practiceActivity.toJson(),
);
return [];
}
return practiceActivity.targetTokens
.map(
(t) {
final tag = t.getMorphTag(practiceActivity.morphFeature!);
if (tag == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "null tag in morph activity",
data: practiceActivity.toJson(),
);
return null;
}
final constructUseType = useType(practiceActivity.activityType);
return OneConstructUse(
lemma: tag,
form: practiceActivity.targetTokens.first.text.content,
constructType: ConstructTypeEnum.morph,
useType: constructUseType,
metadata: metadata,
category: practiceActivity.morphFeature!,
xp: constructUseType.pointValue,
);
},
)
.where((c) => c != null)
.cast<OneConstructUse>()
.toList();
}
}
factory ActivityRecordResponse.fromJson(Map<String, dynamic> json) {
return ActivityRecordResponse(
cId: ConstructIdentifier.fromJson(json['cId'] as Map<String, dynamic>),
text: json['text'] as String?,
audioBytes: json['audio'] as Uint8List?,
imageBytes: json['image'] as Uint8List?,
timestamp: DateTime.parse(json['timestamp'] as String),
// this has a default of 1 to make this backwards compatible
// score was added later and is not present in all records
// currently saved to Matrix
score: json['score'] ?? 1.0,
);
}
Map<String, dynamic> toJson() {
return {
'cId': cId.toJson(),
'text': text,
'audio': audioBytes,
'image': imageBytes,
'timestamp': timestamp.toIso8601String(),
'score': score.toInt(),
};
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ActivityRecordResponse &&
other.text == text &&
other.audioBytes == audioBytes &&
other.imageBytes == imageBytes &&
other.timestamp == timestamp &&
other.score == score &&
other.cId == cId;
}
@override
int get hashCode =>
text.hashCode ^
audioBytes.hashCode ^
imageBytes.hashCode ^
timestamp.hashCode ^
score.hashCode ^
cId.hashCode;
}