get analytics events directly from server, cache last update time for user's l2

pull/1384/head
ggurdin 1 year ago
parent 91a5d8414c
commit 4278f7b196
No known key found for this signature in database
GPG Key ID: A01CB41737CBB478

@ -639,6 +639,7 @@ class ChatController extends State<ChatPageWithRoom>
pangeaController.myAnalytics.setState(
data: {
'eventID': msgEventId,
'eventType': EventTypes.Message,
'roomID': room.id,
'originalSent': originalSent,
'tokensSent': tokensSent,

@ -1,3 +1,5 @@
import 'dart:async';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/match_rule_ids.dart';
@ -25,16 +27,13 @@ class GetAnalyticsController {
Client get client => _pangeaController.matrixState.client;
// A local cache of eventIds and construct uses for messages sent since the last update
/// A local cache of eventIds and construct uses for messages sent since the last update
Map<String, List<OneConstructUse>> get messagesSinceUpdate {
try {
final dynamic locallySaved = _pangeaController.pStoreService.read(
PLocalKey.messagesSinceUpdate,
);
if (locallySaved == null) {
_pangeaController.myAnalytics.setMessagesSinceUpdate({});
return {};
}
if (locallySaved == null) return {};
try {
// try to get the local cache of messages and format them as OneConstructUses
final Map<String, List<dynamic>> cache =
@ -47,7 +46,7 @@ class GetAnalyticsController {
return formattedCache;
} catch (err) {
// if something goes wrong while trying to format the local data, clear it
_pangeaController.myAnalytics.setMessagesSinceUpdate({});
_pangeaController.myAnalytics.clearMessagesSinceUpdate();
return {};
}
} catch (exception, stackTrace) {
@ -70,13 +69,24 @@ class GetAnalyticsController {
debugPrint("getting constructs");
await client.roomsLoading;
// first, try to get a cached list of all uses, if it exists and is valid
final DateTime? lastUpdated = await myAnalyticsLastUpdated();
// don't try to get constructs until last updated time has been loaded
await _pangeaController.myAnalytics.lastUpdatedCompleter.future;
// if forcing a refreshing, clear the cache
if (forceUpdate) clearCache();
// get the last time the user updated their analytics for their current l2
// then try to get local cache of construct uses. lastUpdate time is used to
// determine if cached data is still valid.
final DateTime? lastUpdated = _pangeaController.myAnalytics.lastUpdated ??
await myAnalyticsLastUpdated();
final List<OneConstructUse>? local = getConstructsLocal(
constructType: constructType,
lastUpdated: lastUpdated,
);
if (local != null && !forceUpdate) {
if (local != null) {
debugPrint("returning local constructs");
return local;
}
@ -111,7 +121,12 @@ class GetAnalyticsController {
/// Get the last time the user updated their analytics for their current l2
Future<DateTime?> myAnalyticsLastUpdated() async {
if (l2Code == null) return null;
// this function gets called soon after login, so first
// make sure that the user's l2 is loaded, if the user has set their l2
if (client.userID != null && l2Code == null) {
await _pangeaController.matrixState.client.waitForAccountData();
if (l2Code == null) return null;
}
final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!);
if (analyticsRoom == null) return null;
final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated(
@ -120,6 +135,29 @@ class GetAnalyticsController {
return lastUpdated;
}
/// Get all the construct analytics events for the logged in user
Future<List<ConstructAnalyticsEvent>> allMyConstructs() async {
if (l2Code == null) return [];
final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!);
if (analyticsRoom == null) return [];
return await analyticsRoom.getAnalyticsEvents(userId: client.userID!) ?? [];
}
/// Filter out constructs that are not relevant to the user, specifically those from
/// rooms in which the user is a teacher and those that are interative translation span constructs
Future<List<OneConstructUse>> filterConstructs({
required List<OneConstructUse> unfilteredConstructs,
}) async {
return unfilteredConstructs
.where(
(use) =>
use.lemma != "Try interactive translation" &&
use.lemma != "itStart" ||
use.lemma != MatchRuleIds.interactiveTranslation,
)
.toList();
}
/// Get the cached construct uses for the current user, if it exists
List<OneConstructUse>? getConstructsLocal({
DateTime? lastUpdated,
@ -140,28 +178,6 @@ class GetAnalyticsController {
return null;
}
/// Get all the construct analytics events for the logged in user
Future<List<ConstructAnalyticsEvent>> allMyConstructs() async {
if (l2Code == null) return [];
final Room? analyticsRoom = client.analyticsRoomLocal(l2Code!);
if (analyticsRoom == null) return [];
return await analyticsRoom.getAnalyticsEvents(userId: client.userID!) ?? [];
}
/// Filter out constructs that are not relevant to the user, specifically those from
/// rooms in which the user is a teacher and those that are interative translation span constructs
Future<List<OneConstructUse>> filterConstructs({
required List<OneConstructUse> unfilteredConstructs,
}) async {
final List<String> adminSpaceRooms = await client.teacherRoomIds;
return unfilteredConstructs.where((use) {
if (adminSpaceRooms.contains(use.chatId)) return false;
return use.lemma != "Try interactive translation" &&
use.lemma != "itStart" ||
use.lemma != MatchRuleIds.interactiveTranslation;
}).toList();
}
/// Cache the construct uses for the current user
void cacheConstructs({
required List<OneConstructUse> uses,
@ -175,6 +191,11 @@ class GetAnalyticsController {
);
_cache.add(entry);
}
/// Clear all cached analytics data.
void clearCache() {
_cache.clear();
}
}
class AnalyticsCacheEntry {

@ -156,14 +156,6 @@ class AnalyticsController extends BaseController {
?.cast<ConstructAnalyticsEvent>();
final List<ConstructAnalyticsEvent> allConstructs = roomEvents ?? [];
final List<String> adminSpaceRooms =
await _pangeaController.matrixState.client.teacherRoomIds;
for (final construct in allConstructs) {
construct.content.uses.removeWhere(
(use) => adminSpaceRooms.contains(use.chatId),
);
}
return allConstructs
.where((construct) => construct.content.uses.isNotEmpty)
.toList();

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
@ -7,10 +6,10 @@ import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/practice_activity_record_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/choreo_record.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_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';
@ -29,9 +28,16 @@ class MyAnalyticsController extends BaseController {
String? get userL2 => _pangeaController.languageController.activeL2Code();
/// the last time that matrix analytics events were updated for the user's current l2
DateTime? lastUpdated;
/// Last updated completer. Used to wait for the last
/// updated time to be set before setting analytics data.
Completer<DateTime?> lastUpdatedCompleter = Completer<DateTime?>();
/// the max number of messages that will be cached before
/// an automatic update is triggered
final int _maxMessagesCached = 10;
final int _maxMessagesCached = 1;
/// the number of minutes before an automatic update is triggered
final int _minutesBeforeUpdate = 5;
@ -44,8 +50,9 @@ class MyAnalyticsController extends BaseController {
// Wait for the next sync in the stream to ensure that the pangea controller
// is fully initialized. It will throw an error if it is not.
_pangeaController.matrixState.client.onSync.stream.first
.then((_) => _refreshAnalyticsIfOutdated());
_pangeaController.matrixState.client.onSync.stream.first.then((_) {
_refreshAnalyticsIfOutdated();
});
// Listen to a stream that provides the eventIDs
// of new messages sent by the logged in user
@ -55,23 +62,31 @@ class MyAnalyticsController extends BaseController {
}
/// If analytics haven't been updated in the last day, update them
Future<DateTime?> _refreshAnalyticsIfOutdated() async {
/// wait for the initial sync to finish, so the
/// timeline data from analytics rooms is accurate
if (_client.prevBatch == null) {
await _client.onSync.stream.first;
Future<void> _refreshAnalyticsIfOutdated() async {
// don't set anything is the user is not logged in
if (_pangeaController.matrixState.client.userID == null) return;
try {
// if lastUpdated hasn't been set yet, set it
lastUpdated ??=
await _pangeaController.analytics.myAnalyticsLastUpdated();
} catch (err, s) {
ErrorHandler.logError(
s: s,
e: err,
m: "Failed to get last updated time for analytics",
);
} finally {
// if this is the initial load, complete the lastUpdatedCompleter
if (!lastUpdatedCompleter.isCompleted) {
lastUpdatedCompleter.complete(lastUpdated);
}
}
DateTime? lastUpdated =
await _pangeaController.analytics.myAnalyticsLastUpdated();
final DateTime yesterday = DateTime.now().subtract(_timeSinceUpdate);
if (lastUpdated?.isBefore(yesterday) ?? true) {
debugPrint("analytics out-of-date, updating");
await updateAnalytics();
lastUpdated = await _pangeaController.analytics.myAnalyticsLastUpdated();
}
return lastUpdated;
}
/// Given the data from a newly sent message, format and cache
@ -88,9 +103,12 @@ class MyAnalyticsController extends BaseController {
// extract the relevant data about this message
final String? eventID = data['eventID'];
final String? roomID = data['roomID'];
final String? eventType = data['eventType'];
final PangeaRepresentation? originalSent = data['originalSent'];
final PangeaMessageTokens? tokensSent = data['tokensSent'];
final ChoreoRecord? choreo = data['choreo'];
final PracticeActivityEvent? practiceActivity = data['practiceActivity'];
final PracticeActivityRecordModel? recordModel = data['recordModel'];
if (roomID == null || eventID == null) return;
@ -101,24 +119,38 @@ class MyAnalyticsController extends BaseController {
timeStamp: DateTime.now(),
);
final grammarConstructs = choreo?.grammarConstructUses(metadata: metadata);
final itConstructs = choreo?.itStepsToConstructUses(metadata: metadata);
final vocabUses = tokensSent != null
? originalSent?.vocabUses(
choreo: choreo,
tokens: tokensSent.tokens,
metadata: metadata,
)
: null;
final List<OneConstructUse> constructs = [
...(grammarConstructs ?? []),
...(itConstructs ?? []),
...(vocabUses ?? []),
];
addMessageSinceUpdate(
eventID,
constructs,
);
final List<OneConstructUse> constructs = [];
if (eventType == EventTypes.Message) {
final grammarConstructs =
choreo?.grammarConstructUses(metadata: metadata);
final itConstructs = choreo?.itStepsToConstructUses(metadata: metadata);
final vocabUses = tokensSent != null
? originalSent?.vocabUses(
choreo: choreo,
tokens: tokensSent.tokens,
metadata: metadata,
)
: null;
constructs.addAll([
...(grammarConstructs ?? []),
...(itConstructs ?? []),
...(vocabUses ?? []),
]);
}
if (eventType == PangeaEventTypes.activityRecord &&
practiceActivity != null) {
final activityConstructs = recordModel?.uses(
practiceActivity,
metadata: metadata,
);
constructs.addAll(activityConstructs ?? []);
}
_pangeaController.analytics
.filterConstructs(unfilteredConstructs: constructs)
.then((filtered) => addMessageSinceUpdate(eventID, filtered));
}
/// Add a list of construct uses for a new message to the local
@ -129,10 +161,9 @@ class MyAnalyticsController extends BaseController {
) {
try {
final currentCache = _pangeaController.analytics.messagesSinceUpdate;
if (!currentCache.containsKey(eventID)) {
currentCache[eventID] = constructs;
setMessagesSinceUpdate(currentCache);
}
constructs.addAll(currentCache[eventID] ?? []);
currentCache[eventID] = constructs;
setMessagesSinceUpdate(currentCache);
// if the cached has reached if max-length, update analytics
if (_pangeaController.analytics.messagesSinceUpdate.length >
@ -151,7 +182,7 @@ class MyAnalyticsController extends BaseController {
/// Clears the local cache of recently sent constructs. Called before updating analytics
void clearMessagesSinceUpdate() {
setMessagesSinceUpdate({});
_pangeaController.pStoreService.delete(PLocalKey.messagesSinceUpdate);
}
/// Save the local cache of recently sent constructs to the local storage
@ -168,8 +199,18 @@ class MyAnalyticsController extends BaseController {
analyticsUpdateStream.add(null);
}
/// Prevent concurrent updates to analytics
Completer<void>? _updateCompleter;
/// Updates learning analytics.
///
/// This method is responsible for updating the analytics. It first checks if an update is already in progress
/// by checking the completion status of the [_updateCompleter]. If an update is already in progress, it waits
/// for the completion of the previous update and returns. Otherwise, it creates a new [_updateCompleter] and
/// proceeds with the update process. If the update is successful, it clears any messages that were received
/// since the last update and notifies the [analyticsUpdateStream].
Future<void> updateAnalytics() async {
if (_pangeaController.matrixState.client.userID == null) return;
if (!(_updateCompleter?.isCompleted ?? true)) {
await _updateCompleter!.future;
return;
@ -178,6 +219,7 @@ class MyAnalyticsController extends BaseController {
try {
await _updateAnalytics();
clearMessagesSinceUpdate();
lastUpdated = DateTime.now();
analyticsUpdateStream.add(null);
} catch (err, s) {
ErrorHandler.logError(
@ -191,119 +233,31 @@ class MyAnalyticsController extends BaseController {
}
}
/// top level analytics sending function. Gather recent messages and activity records,
/// convert them into the correct formats, and send them to the analytics room
/// Updates the analytics by sending cached analytics data to the analytics room.
/// The analytics room is determined based on the user's current target language.
Future<void> _updateAnalytics() async {
// if there's no cached construct data, there's nothing to send
if (_pangeaController.analytics.messagesSinceUpdate.isEmpty) return;
// if missing important info, don't send analytics. Could happen if user just signed up.
if (userL2 == null || _client.userID == null) return;
// analytics room for the user and current target language
final Room? analyticsRoom = await _client.getMyAnalyticsRoom(userL2!);
// get the last time analytics were updated for this room
final DateTime? l2AnalyticsLastUpdated =
await analyticsRoom?.analyticsLastUpdated(
_client.userID!,
// and send cached analytics data to the room
await analyticsRoom?.sendConstructsEvent(
_pangeaController.analytics.messagesSinceUpdate.values
.expand((e) => e)
.toList(),
);
}
// all chats in which user is a student
final List<Room> chats = _client.rooms
.where((room) => !room.isSpace && !room.isAnalyticsRoom)
.toList();
// get the recent message events and activity records for each chat
final List<Future<List<Event>>> recentMsgFutures = [];
final List<Future<List<Event>>> recentActivityFutures = [];
for (final Room chat in chats) {
recentMsgFutures.add(
chat.getEventsBySender(
type: EventTypes.Message,
sender: _client.userID!,
since: l2AnalyticsLastUpdated,
),
);
recentActivityFutures.add(
chat.getEventsBySender(
type: PangeaEventTypes.activityRecord,
sender: _client.userID!,
since: l2AnalyticsLastUpdated,
),
);
}
final List<List<Event>> recentMsgs =
(await Future.wait(recentMsgFutures)).toList();
final List<PracticeActivityRecordEvent> recentActivityRecords =
(await Future.wait(recentActivityFutures))
.expand((e) => e)
.map((event) => PracticeActivityRecordEvent(event: event))
.toList();
// get the timelines for each chat
final List<Future<Timeline>> timelineFutures = [];
for (final chat in chats) {
timelineFutures.add(chat.getTimeline());
}
final List<Timeline> timelines = await Future.wait(timelineFutures);
final Map<String, Timeline> timelineMap =
Map.fromIterables(chats.map((e) => e.id), timelines);
//convert into PangeaMessageEvents
final List<List<PangeaMessageEvent>> recentPangeaMessageEvents = [];
for (final (index, eventList) in recentMsgs.indexed) {
recentPangeaMessageEvents.add(
eventList
.map(
(event) => PangeaMessageEvent(
event: event,
timeline: timelines[index],
ownMessage: true,
),
)
.toList(),
);
}
final List<PangeaMessageEvent> allRecentMessages =
recentPangeaMessageEvents.expand((e) => e).toList();
// get constructs for messages
final List<OneConstructUse> recentConstructUses = [];
for (final PangeaMessageEvent message in allRecentMessages) {
recentConstructUses.addAll(message.allConstructUses);
}
// get constructs for practice activities
final List<Future<List<OneConstructUse>>> constructFutures = [];
for (final PracticeActivityRecordEvent activity in recentActivityRecords) {
final Timeline? timeline = timelineMap[activity.event.roomId!];
if (timeline == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "PracticeActivityRecordEvent has null timeline",
data: activity.event.toJson(),
);
continue;
}
constructFutures.add(activity.uses(timeline));
}
final List<List<OneConstructUse>> constructLists =
await Future.wait(constructFutures);
recentConstructUses.addAll(constructLists.expand((e) => e));
//TODO - confirm that this is the correct construct content
// debugger(
// when: kDebugMode,
// );
// ; debugger(
// when: kDebugMode &&
// (allRecentMessages.isNotEmpty || recentActivityRecords.isNotEmpty),
// );
if (recentConstructUses.isNotEmpty || l2AnalyticsLastUpdated == null) {
await analyticsRoom?.sendConstructsEvent(
recentConstructUses,
);
}
/// Reset analytics last updated time to null.
void clearCache() {
_updateTimer?.cancel();
lastUpdated = null;
lastUpdatedCompleter = Completer<DateTime?>();
_refreshAnalyticsIfOutdated();
}
}

@ -71,9 +71,12 @@ class UserController extends BaseController {
}
/// Updates the user's profile with the given [update] function and saves it.
void updateProfile(Profile Function(Profile) update) {
Future<void> updateProfile(
Profile Function(Profile) update, {
waitForDataInSync = false,
}) async {
final Profile updatedProfile = update(profile);
updatedProfile.saveProfileData();
await updatedProfile.saveProfileData(waitForDataInSync: waitForDataInSync);
}
/// Creates a new profile for the user with the given date of birth.

@ -153,16 +153,4 @@ extension AnalyticsClientExtension on Client {
_joinAnalyticsRoomsInAllSpaces();
});
}
Future<Map<String, DateTime?>> _allAnalyticsRoomsLastUpdated() async {
// get the last updated time for each analytics room
final Map<String, DateTime?> lastUpdatedMap = {};
for (final analyticsRoom in allMyAnalyticsRooms) {
final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated(
userID!,
);
lastUpdatedMap[analyticsRoom.id] = lastUpdated;
}
return lastUpdatedMap;
}
}

@ -2,7 +2,6 @@ import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/space_model.dart';
@ -39,15 +38,10 @@ extension PangeaClient on Client {
/// and set up those rooms to be joined by other users.
void migrateAnalyticsRooms() => _migrateAnalyticsRooms();
Future<Map<String, DateTime?>> allAnalyticsRoomsLastUpdated() async =>
await _allAnalyticsRoomsLastUpdated();
// spaces
List<Room> get spacesImTeaching => _spacesImTeaching;
Future<List<Room>> get chatsImAStudentIn async => await _chatsImAStudentIn;
List<Room> get spacesImAStudentIn => _spacesImStudyingIn;
List<Room> get spacesImIn => _spacesImIn;
@ -56,8 +50,6 @@ extension PangeaClient on Client {
// general_info
Future<List<String>> get teacherRoomIds async => await _teacherRoomIds;
Future<List<User>> get myTeachers async => await _myTeachers;
Future<Room> getReportsDM(User teacher, Room space) async =>

@ -1,16 +1,6 @@
part of "client_extension.dart";
extension GeneralInfoClientExtension on Client {
Future<List<String>> get _teacherRoomIds async {
final List<String> adminRoomIds = [];
for (final Room adminSpace in (await _spacesImTeaching)) {
adminRoomIds.add(adminSpace.id);
final List<String> adminSpaceRooms = adminSpace.allSpaceChildRoomIds;
adminRoomIds.addAll(adminSpaceRooms);
}
return adminRoomIds;
}
Future<List<User>> get _myTeachers async {
final List<User> teachers = [];
for (final classRoom in spacesImIn) {

@ -4,18 +4,6 @@ extension SpaceClientExtension on Client {
List<Room> get _spacesImTeaching =>
rooms.where((e) => e.isSpace && e.isRoomAdmin).toList();
Future<List<Room>> get _chatsImAStudentIn async {
final List<String> nowteacherRoomIds = await teacherRoomIds;
return rooms
.where(
(r) =>
!r.isSpace &&
!r.isAnalyticsRoom &&
!nowteacherRoomIds.contains(r.id),
)
.toList();
}
List<Room> get _spacesImStudyingIn =>
rooms.where((e) => e.isSpace && !e.isRoomAdmin).toList();

@ -346,66 +346,51 @@ extension EventsRoomExtension on Room {
// }
// }
// fetch event of a certain type by a certain sender
// since a certain time or up to a certain amount
Future<List<Event>> getEventsBySender({
required String type,
required String sender,
DateTime? since,
/// Get a list of events in the room that are of type [PangeaEventTypes.construct]
/// and have the sender as [userID]. If [count] is provided, the function will
/// return at most [count] events.
Future<List<Event>> getRoomAnalyticsEvents({
String? userID,
int? count,
}) async {
try {
int numberOfSearches = 0;
final Timeline timeline = await getTimeline();
List<Event> relevantEvents() => timeline.events
.where((event) => event.senderId == sender && event.type == type)
.toList();
bool reachedEnd() {
if (since != null) {
return relevantEvents().any(
(event) => event.originServerTs.isBefore(since),
);
}
if (count != null) {
return relevantEvents().length >= count;
}
return false;
}
while (timeline.canRequestHistory && numberOfSearches < 10) {
await timeline.requestHistory(historyCount: 100);
numberOfSearches += 1;
if (!timeline.canRequestHistory) break;
if (reachedEnd()) break;
}
final List<Event> fetchedEvents = timeline.events
.where((event) => event.senderId == sender && event.type == type)
.toList();
if (since != null) {
fetchedEvents.removeWhere(
(event) => event.originServerTs.isBefore(since),
);
}
final List<Event> events = [];
for (Event event in fetchedEvents) {
if (event.relationshipType == RelationshipTypes.edit) continue;
if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
event = event.getDisplayEvent(timeline);
}
events.add(event);
}
userID ??= client.userID;
if (userID == null) return [];
GetRoomEventsResponse resp = await client.getRoomEvents(
id,
Direction.b,
limit: count ?? 100,
filter: jsonEncode(
StateFilter(
types: [
PangeaEventTypes.construct,
],
senders: [userID],
),
),
);
return events;
} catch (err, s) {
if (kDebugMode) rethrow;
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
return [];
int numSearches = 0;
while (numSearches < 10 && resp.end != null) {
if (count != null && resp.chunk.length <= count) break;
final nextResp = await client.getRoomEvents(
id,
Direction.b,
limit: count ?? 100,
filter: jsonEncode(
StateFilter(
types: [
PangeaEventTypes.construct,
],
senders: [userID],
),
),
from: resp.end,
);
nextResp.chunk.addAll(resp.chunk);
resp = nextResp;
numSearches += 1;
}
return resp.chunk.map((e) => Event.fromMatrixEvent(e, this)).toList();
}
}

@ -90,6 +90,16 @@ extension PangeaRoom on Room {
bool isMadeForLang(String langCode) => _isMadeForLang(langCode);
/// Sends construct events to the server.
///
/// The [uses] parameter is a list of [OneConstructUse] objects representing the
/// constructs to be sent. To prevent hitting the maximum event size, the events
/// are chunked into smaller lists. Each chunk is sent as a separate event.
Future<void> sendConstructsEvent(
List<OneConstructUse> uses,
) async =>
await _sendConstructsEvent(uses);
// children_and_parents
List<Room> get joinedChildren => _joinedChildren;

@ -140,33 +140,17 @@ extension AnalyticsRoomExtension on Room {
);
}
Future<ConstructAnalyticsEvent?> _getLastAnalyticsEvent(
String userId,
) async {
final List<Event> events = await getEventsBySender(
type: PangeaEventTypes.construct,
sender: userId,
count: 10,
);
if (events.isEmpty) return null;
final Event event = events.first;
return ConstructAnalyticsEvent(event: event);
}
Future<DateTime?> _analyticsLastUpdated(String userId) async {
final lastEvent = await _getLastAnalyticsEvent(userId);
return lastEvent?.event.originServerTs;
final List<Event> events = await getRoomAnalyticsEvents(count: 1);
if (events.isEmpty) return null;
return events.first.originServerTs;
}
Future<List<ConstructAnalyticsEvent>?> _getAnalyticsEvents({
required String userId,
DateTime? since,
}) async {
final List<Event> events = await getEventsBySender(
type: PangeaEventTypes.construct,
sender: userId,
since: since,
);
final events = await getRoomAnalyticsEvents();
final List<ConstructAnalyticsEvent> analyticsEvents = [];
for (final Event event in events) {
analyticsEvents.add(ConstructAnalyticsEvent(event: event));
@ -192,7 +176,7 @@ extension AnalyticsRoomExtension on Room {
/// The [uses] parameter is a list of [OneConstructUse] objects representing the
/// constructs to be sent. To prevent hitting the maximum event size, the events
/// are chunked into smaller lists. Each chunk is sent as a separate event.
Future<void> sendConstructsEvent(
Future<void> _sendConstructsEvent(
List<OneConstructUse> uses,
) async {
// It's possible that the user has no info to send yet, but to prevent trying

@ -1,12 +1,5 @@
import 'dart:developer';
import 'package:fluffychat/pangea/extensions/pangea_event_extension.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/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/practice_activity_record_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
@ -28,64 +21,4 @@ class PracticeActivityRecordEvent {
_content ??= event.getPangeaContent<PracticeActivityRecordModel>();
return _content!;
}
Future<List<OneConstructUse>> uses(Timeline timeline) async {
try {
final String? parent = event.relationshipEventId;
if (parent == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "PracticeActivityRecordEvent has null event.relationshipEventId",
data: event.toJson(),
);
return [];
}
final Event? practiceEvent =
await timeline.getEventById(event.relationshipEventId!);
if (practiceEvent == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "PracticeActivityRecordEvent has null practiceActivityEvent with id $parent",
data: event.toJson(),
);
return [];
}
final PracticeActivityEvent practiceActivity = PracticeActivityEvent(
event: practiceEvent,
timeline: timeline,
);
final List<OneConstructUse> uses = [];
final List<ConstructIdentifier> constructIds =
practiceActivity.practiceActivity.tgtConstructs;
for (final construct in constructIds) {
uses.add(
OneConstructUse(
lemma: construct.lemma,
constructType: construct.type,
useType: record.useType,
//TODO - find form of construct within the message
//this is related to the feature of highlighting the target construct in the message
form: construct.lemma,
metadata: ConstructUseMetaData(
roomId: event.roomId ?? practiceEvent.roomId ?? timeline.room.id,
eventId: practiceActivity.parentMessageId,
timeStamp: event.originServerTs,
),
),
);
}
return uses;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s, data: event.toJson());
rethrow;
}
}
}

@ -3,9 +3,14 @@
// the user might have selected multiple options before
// finding the answer
import 'dart:developer';
import 'dart:typed_data';
import 'package:fluffychat/pangea/enum/construct_use_type_enum.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/practice_activities.dart/practice_activity_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
class PracticeActivityRecordModel {
final String? question;
@ -79,6 +84,56 @@ class PracticeActivityRecordModel {
}
}
/// 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 [event] parameter is the record event, if available.
/// The [metadata] parameter is the metadata for the construct use, used if the record event isn't available.
///
/// If [event] and [metadata] are both null, an empty list is returned.
///
/// The method iterates over the [tgtConstructs] of the [practiceActivity] and creates a [OneConstructUse] object for each construct.
List<OneConstructUse> uses(
PracticeActivityEvent practiceActivity, {
Event? event,
ConstructUseMetaData? metadata,
}) {
try {
if (event == null && metadata == null) {
debugger(when: kDebugMode);
return [];
}
final List<OneConstructUse> uses = [];
final List<ConstructIdentifier> constructIds =
practiceActivity.practiceActivity.tgtConstructs;
for (final construct in constructIds) {
uses.add(
OneConstructUse(
lemma: construct.lemma,
constructType: construct.type,
useType: useType,
//TODO - find form of construct within the message
//this is related to the feature of highlighting the target construct in the message
form: construct.lemma,
metadata: ConstructUseMetaData(
roomId: event?.roomId ?? metadata!.roomId,
eventId: practiceActivity.parentMessageId,
timeStamp: event?.originServerTs ?? metadata!.timeStamp,
),
),
);
}
return uses;
} catch (e, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: e, s: s, data: event?.toJson());
rethrow;
}
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;

@ -1,11 +1,9 @@
import 'package:flutter/material.dart';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async {
if (await showOkCancelAlertDialog(
useRootNavigator: false,
@ -20,6 +18,14 @@ void pLogoutAction(BuildContext context, {bool? isDestructiveAction}) async {
return;
}
final matrix = Matrix.of(context);
// before wiping out locally cached construct data, save it to the server
await MatrixState.pangeaController.myAnalytics.updateAnalytics();
// Reset cached analytics data
MatrixState.pangeaController.myAnalytics.clearCache();
MatrixState.pangeaController.analytics.clearCache();
await showFutureLoadingDialog(
context: context,
future: () => matrix.client.logout(),

@ -42,10 +42,14 @@ class LearningProgressIndicatorsState
/// Grammar constructs model
ConstructListModel? errors;
bool loading = true;
@override
void initState() {
super.initState();
updateAnalyticsData();
updateAnalyticsData().then((_) {
setState(() => loading = false);
});
// listen for changes to analytics data and update the UI
_onAnalyticsUpdate = _pangeaController
.myAnalytics.analyticsUpdateStream.stream
@ -77,6 +81,7 @@ class LearningProgressIndicatorsState
type: ConstructTypeEnum.grammar,
uses: localUses,
);
setState(() {});
return;
}
@ -93,7 +98,8 @@ class LearningProgressIndicatorsState
type: ConstructTypeEnum.grammar,
uses: allConstructs,
);
setState(() {});
if (mounted) setState(() {});
}
/// Get the number of points for a given progress indicator
@ -136,6 +142,10 @@ class LearningProgressIndicatorsState
@override
Widget build(BuildContext context) {
if (Matrix.of(context).client.userID == null) {
return const SizedBox();
}
final levelBar = Container(
height: 20,
width: levelBarWidth,
@ -214,6 +224,7 @@ class LearningProgressIndicatorsState
points: getProgressPoints(indicator),
onTap: () {},
progressIndicator: indicator,
loading: loading,
),
)
.toList(),
@ -222,9 +233,6 @@ class LearningProgressIndicatorsState
),
),
Container(
// decoration: BoxDecoration(
// border: Border.all(color: Colors.green),
// ),
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Stack(

@ -7,12 +7,14 @@ class ProgressIndicatorBadge extends StatelessWidget {
final int? points;
final VoidCallback onTap;
final ProgressIndicatorEnum progressIndicator;
final bool loading;
const ProgressIndicatorBadge({
super.key,
required this.points,
required this.onTap,
required this.progressIndicator,
required this.loading,
});
@override
@ -33,9 +35,9 @@ class ProgressIndicatorBadge extends StatelessWidget {
color: progressIndicator.color(context),
),
const SizedBox(width: 5),
points != null
!loading
? AnimatedCount(
count: points!,
count: points ?? 0,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,

@ -1,4 +1,5 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.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/practice_activities.dart/practice_activity_record_model.dart';
@ -102,6 +103,20 @@ class MessagePracticeActivityCardState extends State<PracticeActivityCard> {
},
);
return null;
}).then((event) {
// The record event is processed into construct uses for learning analytics, so if the
// event went through without error, send it to analytics to be processed
if (event != null && currentActivity != null) {
MatrixState.pangeaController.myAnalytics.setState(
data: {
'eventID': widget.pangeaMessageEvent.eventId,
'eventType': PangeaEventTypes.activityRecord,
'roomID': event.room.id,
'practiceActivity': currentActivity!,
'recordModel': currentRecordModel!,
},
);
}
}).whenComplete(() => setState(() => sending = false));
}

@ -91,15 +91,22 @@ Future<void> pLanguageDialog(
context: context,
future: () async {
try {
pangeaController.userController
.updateProfile((profile) {
profile.userSettings.sourceLanguage =
selectedSourceLanguage.langCode;
profile.userSettings.targetLanguage =
selectedTargetLanguage.langCode;
return profile;
pangeaController.userController.updateProfile(
(profile) {
profile.userSettings.sourceLanguage =
selectedSourceLanguage.langCode;
profile.userSettings.targetLanguage =
selectedTargetLanguage.langCode;
return profile;
},
waitForDataInSync: true,
).then((_) {
// if the profile update is successful, reset cached analytics
// data, since analytics data corresponds to the user's L2
pangeaController.myAnalytics.clearCache();
pangeaController.analytics.clearCache();
Navigator.pop(context);
});
Navigator.pop(context);
} catch (err, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);

Loading…
Cancel
Save