move student summary analytics events to analytics rooms

pull/1183/head
ggurdin 1 year ago
parent ffe49bd748
commit 12e364a32d

@ -22,7 +22,6 @@ import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dar
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/class_model.dart';
import 'package:fluffychat/pangea/models/representation_content_model.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/utils/firebase_analytics.dart';
@ -643,34 +642,8 @@ class ChatController extends State<ChatPageWithRoom>
);
return;
}
// ensure that analytics room exists / is created for the active langCode
await room.ensureAnalyticsRoomExists();
pangeaController.myAnalytics.handleMessage(
room,
RecentMessageRecord(
eventId: msgEventId,
chatId: room.id,
useType: useType ?? UseType.un,
time: DateTime.now(),
),
isEdit: previousEdit != null,
);
if (choreo != null &&
tokensSent != null &&
originalSent?.langCode ==
pangeaController.languageController
.activeL2Code(roomID: room.id)) {
pangeaController.myAnalytics.saveConstructsMixed(
[
// ...choreo.toVocabUse(tokensSent.tokens, room.id, msgEventId),
...choreo.toGrammarConstructUse(msgEventId, room.id),
],
originalSent!.langCode,
isEdit: previousEdit != null,
);
}
},
onError: (err, stack) => ErrorHandler.logError(e: err, s: stack),
);

@ -3,7 +3,6 @@ import 'dart:developer';
import 'package:file_picker/file_picker.dart';
import 'package:fluffychat/pages/new_space/new_space_view.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_rules_editor.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
@ -77,7 +76,6 @@ class NewSpaceController extends State<NewSpace> {
stateKey: '',
content: {
'events': {
PangeaEventTypes.studentAnalyticsSummary: 0,
EventTypes.spaceChild: 0,
},
'users_default': 0,

@ -109,4 +109,7 @@ class ModelKey {
"discussion_trigger_reaction_enabled";
static const String discussionTriggerReactionKey =
"discussion_trigger_reaction_key";
static const String prevEventId = "prev_event_id";
static const String prevLastUpdated = "prev_last_updated";
}

@ -6,14 +6,16 @@ class PangeaEventTypes {
static const rules = "p.rules";
static const studentAnalyticsSummary = "pangea.usranalytics";
// static const studentAnalyticsSummary = "pangea.usranalytics";
static const summaryAnalytics = "pangea.summaryAnalytics";
static const construct = "pangea.construct";
static const translation = "pangea.translation";
static const tokens = "pangea.tokens";
static const choreoRecord = "pangea.record";
static const representation = "pangea.representation";
static const vocab = "p.vocab";
// static const vocab = "p.vocab";
static const roomInfo = "pangea.roomtopic";
static const audio = "p.audio";

@ -2,9 +2,11 @@ import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/match_rule_ids.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
import 'package:fluffychat/pangea/models/constructs_event.dart';
import 'package:fluffychat/pangea/models/summary_analytics_event.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
@ -13,16 +15,14 @@ import 'package:matrix/matrix.dart';
import '../constants/class_default_values.dart';
import '../extensions/client_extension.dart';
import '../extensions/pangea_room_extension.dart';
import '../matrix_event_wrappers/construct_analytics_event.dart';
import '../models/chart_analytics_model.dart';
import '../models/student_analytics_event.dart';
import 'base_controller.dart';
import 'pangea_controller.dart';
class AnalyticsController extends BaseController {
late PangeaController _pangeaController;
final List<CacheModel> _cachedModels = [];
final List<AnalyticsCacheModel> _cachedAnalyticsModels = [];
final List<ConstructCacheEntry> _cachedConstructs = [];
AnalyticsController(PangeaController pangeaController) : super() {
@ -57,232 +57,327 @@ class AnalyticsController extends BaseController {
);
}
Future<List<ChartAnalyticsModel?>> allClassAnalytics() async {
final List<Future<ChartAnalyticsModel?>> classAnalyticFutures = [];
for (final classRoom in (await _pangeaController
.matrixState.client.classesAndExchangesImTeaching)) {
classAnalyticFutures.add(
getAnalytics(classRoom: classRoom),
Future<List<SummaryAnalyticsEvent>> allMySummaryAnalytics() async {
final analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
final List<SummaryAnalyticsEvent> allEvents = [];
for (final Room analyticsRoom in analyticsRooms) {
final List<SummaryAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.summaryAnalytics,
since: currentAnalyticsTimeSpan.cutOffDate,
))
?.cast<SummaryAnalyticsEvent>();
allEvents.addAll(roomEvents ?? []);
}
return allEvents;
}
Future<List<SummaryAnalyticsEvent>> allSpaceMemberAnalytics(
Room space,
) async {
final langCode = _pangeaController.languageController.activeL2Code(
roomID: space.id,
);
final List<SummaryAnalyticsEvent> analyticsEvents = [];
await space.postLoad();
await space.requestParticipants();
for (final student in space.students) {
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(langCode, student.id);
if (analyticsRoom != null) {
final List<SummaryAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.summaryAnalytics,
since: currentAnalyticsTimeSpan.cutOffDate,
))
?.cast<SummaryAnalyticsEvent>();
analyticsEvents.addAll(roomEvents ?? []);
}
}
final List<String> spaceChildrenIds = space.spaceChildren
.map((e) => e.roomId)
.where((e) => e != null)
.cast<String>()
.toList();
final List<SummaryAnalyticsEvent> allAnalyticsEvents = [];
for (final analyticsEvent in analyticsEvents) {
analyticsEvent.content.messages.removeWhere(
(msg) => !spaceChildrenIds.contains(msg.chatId),
);
allAnalyticsEvents.add(analyticsEvent);
}
return Future.wait(classAnalyticFutures);
return allAnalyticsEvents;
}
ChartAnalyticsModel? getAnalyticsLocal({
TimeSpan? timeSpan,
String? classId,
String? studentId,
String? chatId,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
bool forceUpdate = false,
bool updateExpired = false,
}) {
timeSpan ??= currentAnalyticsTimeSpan;
final int index = _cachedModels.indexWhere(
final int index = _cachedAnalyticsModels.indexWhere(
(e) =>
(e.timeSpan == timeSpan) &&
(e.classId == classId) &&
(e.studentId == studentId) &&
(e.chatId == chatId),
(e.defaultSelected.id == defaultSelected.id) &&
(e.defaultSelected.type == defaultSelected.type) &&
(e.selected?.id == selected?.id) &&
(e.selected?.type == selected?.type),
);
if (index != -1) {
if ((updateExpired && _cachedModels[index].isExpired) || forceUpdate) {
_cachedModels.removeAt(index);
if ((updateExpired && _cachedAnalyticsModels[index].isExpired) ||
forceUpdate) {
_cachedAnalyticsModels.removeAt(index);
} else {
return _cachedModels[index].chartAnalyticsModel;
return _cachedAnalyticsModels[index].chartAnalyticsModel;
}
}
return null;
}
Future<ChartAnalyticsModel> getAnalytics({
void cacheAnalytics({
required ChartAnalyticsModel chartAnalyticsModel,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
TimeSpan? timeSpan,
Room? classRoom,
}) {
_cachedAnalyticsModels.add(
AnalyticsCacheModel(
timeSpan: timeSpan ?? currentAnalyticsTimeSpan,
chartAnalyticsModel: chartAnalyticsModel,
defaultSelected: defaultSelected,
selected: selected,
),
);
}
List<SummaryAnalyticsEvent> filterStudentAnalytics(
List<SummaryAnalyticsEvent> unfiltered,
String? studentId,
String? chatId,
bool forceUpdate = false,
}) async {
timeSpan ??= currentAnalyticsTimeSpan;
try {
final cachedModel = getAnalyticsLocal(
classId: classRoom?.id,
studentId: studentId,
chatId: chatId,
updateExpired: true,
forceUpdate: forceUpdate,
);
if (cachedModel != null) return cachedModel;
// debugger(when: classRoom?.displayname.contains('clizass') ?? false);
late List<StudentAnalyticsEvent?> studentAnalyticsSummaryEvents;
if (classRoom == null) {
if (studentId == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "studentId should have been defined",
s: StackTrace.current,
);
} else {
studentAnalyticsSummaryEvents =
await _pangeaController.myAnalytics.allMyAnalyticsEvents();
}
} else {
if (studentId != null) {
studentAnalyticsSummaryEvents = [
await classRoom.getStudentAnalytics(studentId),
];
} else {
studentAnalyticsSummaryEvents = await classRoom.getClassAnalytics();
}
if (studentId != null && chatId != null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "studentId and chatId should have both been defined",
s: StackTrace.current,
);
studentAnalyticsSummaryEvents = [];
}
}
) {
final List<SummaryAnalyticsEvent> filtered =
List<SummaryAnalyticsEvent>.from(unfiltered);
filtered.removeWhere((e) => e.event.senderId != studentId);
return filtered;
}
final List<RecentMessageRecord> msgs = [];
for (final event in studentAnalyticsSummaryEvents) {
if (event != null) {
msgs.addAll(event.content.messages);
} else {
debugPrint("studentAnalyticsSummaryEvent is null");
}
}
List<SummaryAnalyticsEvent> filterRoomAnalytics(
List<SummaryAnalyticsEvent> unfiltered,
String? roomID,
) {
List<SummaryAnalyticsEvent> filtered = [...unfiltered];
filtered = filtered
.where(
(e) => (e.content).messages.any((u) => u.chatId == roomID),
)
.toList();
filtered.forEachIndexed(
(i, _) => (filtered[i].content).messages.removeWhere(
(u) => u.chatId != roomID,
),
);
return filtered;
}
final newModel = ChartAnalyticsModel(
timeSpan: timeSpan,
msgs: msgs,
chatId: chatId,
);
List<SummaryAnalyticsEvent> filterPrivateChatAnalytics(
List<SummaryAnalyticsEvent> unfiltered,
Room? space,
) {
final List<String> directChatIds =
space?.childrenAndGrandChildrenDirectChatIds ?? [];
List<SummaryAnalyticsEvent> filtered =
List<SummaryAnalyticsEvent>.from(unfiltered);
filtered = filtered.where((e) {
return (e.content).messages.any(
(u) => directChatIds.contains(u.chatId),
);
}).toList();
filtered.forEachIndexed(
(i, _) => (filtered[i].content).messages.removeWhere(
(u) => !directChatIds.contains(u.chatId),
),
);
return filtered;
}
_cachedModels.add(
CacheModel(
timeSpan: timeSpan,
classId: classRoom?.id,
studentId: studentId,
chatId: chatId,
chartAnalyticsModel: newModel,
),
);
List<SummaryAnalyticsEvent> filterSpaceAnalytics(
List<SummaryAnalyticsEvent> unfiltered,
String spaceId,
) {
final selectedSpace =
_pangeaController.matrixState.client.getRoomById(spaceId);
final List<String> chatIds = selectedSpace?.spaceChildren
.map((e) => e.roomId)
.where((e) => e != null)
.cast<String>()
.toList() ??
[];
List<SummaryAnalyticsEvent> filtered =
List<SummaryAnalyticsEvent>.from(unfiltered);
filtered = filtered
.where(
(e) => (e.content).messages.any((u) => chatIds.contains(u.chatId)),
)
.toList();
return newModel;
} catch (err, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
return ChartAnalyticsModel(msgs: [], timeSpan: timeSpan, chatId: chatId);
filtered.forEachIndexed(
(i, _) => (filtered[i].content).messages.removeWhere(
(u) => !chatIds.contains(u.chatId),
),
);
return filtered;
}
Future<List<SummaryAnalyticsEvent>> filterAnalytics({
required List<SummaryAnalyticsEvent> unfilteredAnalytics,
required AnalyticsSelected defaultSelected,
Room? space,
AnalyticsSelected? selected,
}) async {
switch (selected?.type) {
case null:
return unfilteredAnalytics;
case AnalyticsEntryType.student:
if (defaultSelected.type != AnalyticsEntryType.space) {
throw Exception(
"student filtering not available for default filter ${defaultSelected.type}",
);
}
return filterStudentAnalytics(unfilteredAnalytics, selected?.id);
case AnalyticsEntryType.room:
return filterRoomAnalytics(unfilteredAnalytics, selected?.id);
case AnalyticsEntryType.privateChats:
if (defaultSelected.type == AnalyticsEntryType.student) {
throw "private chat filtering not available for my analytics";
}
return filterPrivateChatAnalytics(unfilteredAnalytics, space);
case AnalyticsEntryType.space:
return filterSpaceAnalytics(unfilteredAnalytics, selected!.id);
default:
throw Exception("invalid filter type - ${selected?.type}");
}
}
Future<ChartAnalyticsModel> getAnalyticsForPrivateChats({
TimeSpan? timeSpan,
required Room? classRoom,
Future<ChartAnalyticsModel> getAnalytics({
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
bool forceUpdate = false,
}) async {
timeSpan ??= currentAnalyticsTimeSpan;
try {
if (classRoom == null) {
return ChartAnalyticsModel(msgs: [], timeSpan: timeSpan);
}
final cachedModel = getAnalyticsLocal(
classId: classRoom.id,
studentId: null,
chatId: AnalyticsEntryType.privateChats.toString(),
updateExpired: true,
final local = getAnalyticsLocal(
defaultSelected: defaultSelected,
selected: selected,
forceUpdate: forceUpdate,
);
if (cachedModel != null) return cachedModel;
final List<StudentAnalyticsEvent?> studentAnalyticsSummaryEvents =
await classRoom.getClassAnalytics();
final List<String> directChatIds =
classRoom.childrenAndGrandChildrenDirectChatIds;
final List<RecentMessageRecord> msgs = [];
for (final event in studentAnalyticsSummaryEvents) {
if (event != null) {
msgs.addAll(
event.content.messages
.where((m) => directChatIds.contains(m.chatId)),
if (local != null && !forceUpdate) {
return local;
}
await _pangeaController.matrixState.client.roomsLoading;
Room? space;
if (defaultSelected.type == AnalyticsEntryType.space) {
space = _pangeaController.matrixState.client.getRoomById(
defaultSelected.id,
);
if (space == null) {
ErrorHandler.logError(
m: "space not found in getAnalytics",
data: {
"defaultSelected": defaultSelected,
"selected": selected,
},
);
return ChartAnalyticsModel(
msgs: [],
timeSpan: currentAnalyticsTimeSpan,
);
} else {
debugPrint("studentAnalyticsSummaryEvent is null");
}
}
final newModel = ChartAnalyticsModel(
timeSpan: timeSpan,
msgs: msgs,
chatId: null,
final List<SummaryAnalyticsEvent> summaryEvents =
defaultSelected.type == AnalyticsEntryType.space
? await allSpaceMemberAnalytics(space!)
: await allMySummaryAnalytics();
final List<SummaryAnalyticsEvent> filteredAnalytics =
await filterAnalytics(
unfilteredAnalytics: summaryEvents,
defaultSelected: defaultSelected,
space: space,
selected: selected,
);
_cachedModels.add(
CacheModel(
timeSpan: timeSpan,
classId: classRoom.id,
studentId: null,
chatId: AnalyticsEntryType.privateChats.toString(),
chartAnalyticsModel: newModel,
),
final ChartAnalyticsModel newModel = ChartAnalyticsModel(
timeSpan: currentAnalyticsTimeSpan,
msgs: filteredAnalytics
.map((event) => event.content.messages)
.expand((msgs) => msgs)
.toList(),
);
if (local == null) {
cacheAnalytics(
chartAnalyticsModel: newModel,
defaultSelected: defaultSelected,
selected: selected,
timeSpan: currentAnalyticsTimeSpan,
);
}
return newModel;
} catch (err, s) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
return ChartAnalyticsModel(msgs: [], timeSpan: timeSpan);
return ChartAnalyticsModel(
msgs: [],
timeSpan: currentAnalyticsTimeSpan,
);
}
}
List<ConstructEvent>? _constructs;
bool settingConstructs = false;
List<ConstructEvent>? get constructs => _constructs;
String? getLangCode({
Room? space,
String? roomID,
}) {
final String? targetRoomID = space?.id ?? roomID;
final String? roomLangCode =
_pangeaController.languageController.activeL2Code(roomID: targetRoomID);
final String? userLangCode =
_pangeaController.languageController.userL2?.langCode;
return roomLangCode ?? userLangCode;
}
//////////////////////////// CONSTRUCTS ////////////////////////////
Future<Room> myAnalyticsRoom(String langCode) =>
_pangeaController.matrixState.client.getMyAnalyticsRoom(langCode);
List<ConstructAnalyticsEvent>? _constructs;
bool settingConstructs = false;
Room? studentAnalyticsRoom(String studentId, String langCode) =>
_pangeaController.matrixState.client.analyticsRoomLocal(
langCode,
studentId,
);
List<ConstructAnalyticsEvent>? get constructs => _constructs;
Future<List<ConstructEvent>> allMyConstructs(
String langCode, {
Future<List<ConstructAnalyticsEvent>> allMyConstructs({
ConstructType? type,
}) async {
final Room analyticsRoom = await myAnalyticsRoom(langCode);
final List<String> adminSpaceRooms =
await _pangeaController.matrixState.client.teacherRoomIds;
final List<Room> analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
List<ConstructAnalyticsEvent> allConstructs = [];
for (final Room analyticsRoom in analyticsRooms) {
final List<ConstructAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.construct,
))
?.cast<ConstructAnalyticsEvent>();
allConstructs.addAll(roomEvents ?? []);
}
final allConstructs = type == null
? await analyticsRoom.allConstructEvents
: (await analyticsRoom.allConstructEvents)
.where((e) => e.content.type == type)
.toList();
allConstructs = type == null
? allConstructs
: allConstructs.where((e) => e.content.type == type).toList();
for (int i = 0; i < allConstructs.length; i++) {
final construct = allConstructs[i];
final uses = construct.content.uses;
uses.removeWhere((u) => adminSpaceRooms.contains(u.chatId));
final List<String> adminSpaceRooms =
await _pangeaController.matrixState.client.teacherRoomIds;
for (final construct in allConstructs) {
final lemmaUses = construct.content.uses;
for (final lemmaUse in lemmaUses) {
lemmaUse.uses.removeWhere((u) => adminSpaceRooms.contains(u.chatId));
}
}
return allConstructs
@ -290,39 +385,56 @@ class AnalyticsController extends BaseController {
.toList();
}
Future<List<ConstructEvent>> allSpaceMemberConstructs(
Room space,
String langCode, {
Future<List<ConstructAnalyticsEvent>> allSpaceMemberConstructs(
Room space, {
ConstructType? type,
}) async {
final List<Future<List<ConstructEvent>>> constructEventFutures = [];
await space.postLoad();
await space.requestParticipants();
final String? langCode = _pangeaController.languageController.activeL2Code(
roomID: space.id,
);
if (langCode == null) {
ErrorHandler.logError(
m: "langCode missing in allSpaceMemberConstructs",
data: {
"space": space,
},
);
return [];
}
final List<ConstructAnalyticsEvent> constructEvents = [];
for (final student in space.students) {
final Room? room = _pangeaController.matrixState.client
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(langCode, student.id);
if (room != null) constructEventFutures.add(room.allConstructEvents);
if (analyticsRoom != null) {
final List<ConstructAnalyticsEvent>? roomEvents =
(await analyticsRoom.getAnalyticsEvents(
type: PangeaEventTypes.construct,
))
?.cast<ConstructAnalyticsEvent>();
constructEvents.addAll(roomEvents ?? []);
}
}
final List<List<ConstructEvent>> constructLists =
await Future.wait(constructEventFutures);
final List<String> spaceChildrenIds = space.spaceChildren
.map((e) => e.roomId)
.where((e) => e != null)
.cast<String>()
.toList();
final List<ConstructEvent> allConstructs = [];
for (final constructList in constructLists) {
for (int i = 0; i < constructList.length; i++) {
final construct = constructList[i];
final uses = construct.content.uses;
uses.removeWhere((u) => !spaceChildrenIds.contains(u.chatId));
final List<ConstructAnalyticsEvent> allConstructs = [];
for (final constructEvent in constructEvents) {
final lemmaUses = constructEvent.content.uses;
for (final lemmaUse in lemmaUses) {
lemmaUse.uses.removeWhere((u) => !spaceChildrenIds.contains(u.chatId));
}
if (constructEvent.content.uses.isNotEmpty) {
allConstructs.add(constructEvent);
}
allConstructs.addAll(
constructList.where((e) => e.content.uses.isNotEmpty),
);
}
return type == null
@ -330,51 +442,49 @@ class AnalyticsController extends BaseController {
: allConstructs.where((e) => e.content.type == type).toList();
}
List<ConstructEvent> filterStudentConstructs(
List<ConstructEvent> unfilteredConstructs,
List<ConstructAnalyticsEvent> filterStudentConstructs(
List<ConstructAnalyticsEvent> unfilteredConstructs,
String? studentId,
) {
final List<ConstructEvent> filtered =
List<ConstructEvent>.from(unfilteredConstructs);
filtered.removeWhere((e) => e.event.senderId != studentId);
final List<ConstructAnalyticsEvent> filtered =
List<ConstructAnalyticsEvent>.from(unfilteredConstructs);
filtered.removeWhere((element) => element.event.senderId != studentId);
return filtered;
}
List<ConstructEvent> filterRoomConstructs(
List<ConstructEvent> unfilteredConstructs,
List<ConstructAnalyticsEvent> filterRoomConstructs(
List<ConstructAnalyticsEvent> unfilteredConstructs,
String? roomID,
) {
List<ConstructEvent> filtered = [...unfilteredConstructs];
filtered = unfilteredConstructs
.where((e) => e.content.uses.any((u) => u.chatId == roomID))
.toList();
filtered.forEachIndexed(
(i, _) => filtered[i].content.uses.removeWhere((u) => u.chatId != roomID),
);
final List<ConstructAnalyticsEvent> filtered = [...unfilteredConstructs];
for (final construct in filtered) {
final lemmaUses = construct.content.uses;
for (final lemmaUse in lemmaUses) {
lemmaUse.uses.removeWhere((u) => u.chatId != roomID);
}
}
return filtered;
}
List<ConstructEvent> filterPrivateChatConstructs(
List<ConstructEvent> unfilteredConstructs,
List<ConstructAnalyticsEvent> filterPrivateChatConstructs(
List<ConstructAnalyticsEvent> unfilteredConstructs,
Room parentSpace,
) {
final List<String> directChatIds =
parentSpace.childrenAndGrandChildrenDirectChatIds;
List<ConstructEvent> filtered =
List<ConstructEvent>.from(unfilteredConstructs);
filtered = filtered.where((e) {
return e.content.uses.any((u) => directChatIds.contains(u.chatId));
}).toList();
filtered.forEachIndexed(
(i, _) => filtered[i].content.uses.removeWhere(
(u) => !directChatIds.contains(u.chatId),
),
);
final List<ConstructAnalyticsEvent> filtered =
List<ConstructAnalyticsEvent>.from(unfilteredConstructs);
for (final construct in filtered) {
final lemmaUses = construct.content.uses;
for (final lemmaUse in lemmaUses) {
lemmaUse.uses.removeWhere((u) => !directChatIds.contains(u.chatId));
}
}
return filtered;
}
List<ConstructEvent> filterSpaceConstructs(
List<ConstructEvent> unfilteredConstructs,
List<ConstructAnalyticsEvent> filterSpaceConstructs(
List<ConstructAnalyticsEvent> unfilteredConstructs,
Room space,
) {
final List<String> chatIds = space.spaceChildren
@ -383,21 +493,20 @@ class AnalyticsController extends BaseController {
.cast<String>()
.toList();
List<ConstructEvent> filtered =
List<ConstructEvent>.from(unfilteredConstructs);
filtered = filtered
.where((e) => e.content.uses.any((u) => chatIds.contains(u.chatId)))
.toList();
final List<ConstructAnalyticsEvent> filtered =
List<ConstructAnalyticsEvent>.from(unfilteredConstructs);
for (final construct in filtered) {
final lemmaUses = construct.content.uses;
for (final lemmaUse in lemmaUses) {
lemmaUse.uses.removeWhere((u) => !chatIds.contains(u.chatId));
}
}
filtered.forEachIndexed(
(i, _) => filtered[i].content.uses.removeWhere(
(u) => !chatIds.contains(u.chatId),
),
);
return filtered;
}
List<ConstructEvent>? getConstructsLocal({
List<ConstructAnalyticsEvent>? getConstructsLocal({
required TimeSpan timeSpan,
required ConstructType constructType,
required AnalyticsSelected defaultSelected,
@ -419,7 +528,7 @@ class AnalyticsController extends BaseController {
void cacheConstructs({
required ConstructType constructType,
required List<ConstructEvent> events,
required List<ConstructAnalyticsEvent> events,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
}) {
@ -434,14 +543,13 @@ class AnalyticsController extends BaseController {
);
}
Future<List<ConstructEvent>> getMyConstructs({
Future<List<ConstructAnalyticsEvent>> getMyConstructs({
required AnalyticsSelected defaultSelected,
required ConstructType constructType,
required String langCode,
AnalyticsSelected? selected,
}) async {
final List<ConstructEvent> unfilteredConstructs = await allMyConstructs(
langCode,
final List<ConstructAnalyticsEvent> unfilteredConstructs =
await allMyConstructs(
type: constructType,
);
@ -451,39 +559,34 @@ class AnalyticsController extends BaseController {
return filterConstructs(
unfilteredConstructs: unfilteredConstructs,
langCode: langCode,
space: space,
defaultSelected: defaultSelected,
selected: selected,
);
}
Future<List<ConstructEvent>> getSpaceConstructs({
Future<List<ConstructAnalyticsEvent>> getSpaceConstructs({
required ConstructType constructType,
required Room space,
required AnalyticsSelected defaultSelected,
required String langCode,
AnalyticsSelected? selected,
}) async {
final List<ConstructEvent> unfilteredConstructs =
final List<ConstructAnalyticsEvent> unfilteredConstructs =
await allSpaceMemberConstructs(
space,
langCode,
type: constructType,
);
return filterConstructs(
unfilteredConstructs: unfilteredConstructs,
langCode: langCode,
space: space,
defaultSelected: defaultSelected,
selected: selected,
);
}
Future<List<ConstructEvent>> filterConstructs({
required List<ConstructEvent> unfilteredConstructs,
required String langCode,
Future<List<ConstructAnalyticsEvent>> filterConstructs({
required List<ConstructAnalyticsEvent> unfilteredConstructs,
required AnalyticsSelected defaultSelected,
Room? space,
AnalyticsSelected? selected,
@ -495,10 +598,12 @@ class AnalyticsController extends BaseController {
for (int i = 0; i < unfilteredConstructs.length; i++) {
final construct = unfilteredConstructs[i];
final uses = construct.content.uses;
uses.removeWhere(
(u) => u.timeStamp.isBefore(currentAnalyticsTimeSpan.cutOffDate),
);
final lemmaUses = construct.content.uses;
for (final lemmaUse in lemmaUses) {
lemmaUse.uses.removeWhere(
(u) => u.timeStamp.isBefore(currentAnalyticsTimeSpan.cutOffDate),
);
}
}
unfilteredConstructs.removeWhere((e) => e.content.uses.isEmpty);
@ -512,12 +617,7 @@ class AnalyticsController extends BaseController {
"student filtering not available for default filter ${defaultSelected.type}",
);
}
final Room? analyticsRoom =
studentAnalyticsRoom(selected!.id, langCode);
if (analyticsRoom == null) {
throw Exception("analyticsRoom missing in filterConstructs");
}
return filterStudentConstructs(unfilteredConstructs, selected.id);
return filterStudentConstructs(unfilteredConstructs, selected!.id);
case AnalyticsEntryType.room:
return filterRoomConstructs(unfilteredConstructs, selected?.id);
case AnalyticsEntryType.privateChats:
@ -531,14 +631,14 @@ class AnalyticsController extends BaseController {
}
}
Future<List<ConstructEvent>?> setConstructs({
Future<List<ConstructAnalyticsEvent>?> setConstructs({
required ConstructType constructType,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
bool removeIT = false,
bool forceUpdate = false,
}) async {
final List<ConstructEvent>? local = getConstructsLocal(
final List<ConstructAnalyticsEvent>? local = getConstructsLocal(
timeSpan: currentAnalyticsTimeSpan,
constructType: constructType,
defaultSelected: defaultSelected,
@ -559,50 +659,31 @@ class AnalyticsController extends BaseController {
);
}
final String? roomID = space?.id ?? selected?.id;
final String? langCode = getLangCode(
space: space,
roomID: roomID,
);
if (langCode == null) {
ErrorHandler.logError(
m: "langCode missing in getConstructs",
data: {
"constructType": constructType,
"AnalyticsEntryType": defaultSelected.type,
"AnalyticsEntryId": defaultSelected.id,
"space": space,
},
);
throw "langCode missing in getConstructs";
}
final filteredConstructs = space == null
? await getMyConstructs(
constructType: constructType,
langCode: langCode,
defaultSelected: defaultSelected,
selected: selected,
)
: await getSpaceConstructs(
constructType: constructType,
space: space,
langCode: langCode,
defaultSelected: defaultSelected,
selected: selected,
);
_constructs = removeIT
? filteredConstructs
.where(
(element) =>
element.content.lemma != "Try interactive translation" &&
element.content.lemma != "itStart" &&
element.content.lemma != MatchRuleIds.interactiveTranslation,
)
.toList()
: filteredConstructs;
if (removeIT) {
for (final construct in filteredConstructs) {
construct.content.uses.removeWhere(
(element) =>
element.lemma == "Try interactive translation" ||
element.lemma == "itStart" ||
element.lemma == MatchRuleIds.interactiveTranslation,
);
}
}
_constructs = filteredConstructs;
if (local == null) {
cacheConstructs(
@ -621,7 +702,7 @@ class AnalyticsController extends BaseController {
class ConstructCacheEntry {
final TimeSpan timeSpan;
final ConstructType type;
final List<ConstructEvent> events;
final List<ConstructAnalyticsEvent> events;
final AnalyticsSelected defaultSelected;
AnalyticsSelected? selected;
@ -634,20 +715,18 @@ class ConstructCacheEntry {
});
}
class CacheModel {
class AnalyticsCacheModel {
TimeSpan timeSpan;
ChartAnalyticsModel chartAnalyticsModel;
String? classId;
String? chatId;
String? studentId;
final AnalyticsSelected defaultSelected;
AnalyticsSelected? selected;
late DateTime _createdAt;
CacheModel({
AnalyticsCacheModel({
required this.timeSpan,
required this.classId,
required this.chartAnalyticsModel,
required this.chatId,
required this.studentId,
required this.defaultSelected,
this.selected,
}) {
_createdAt = DateTime.now();
}
@ -656,10 +735,3 @@ class CacheModel {
DateTime.now().difference(_createdAt).inMinutes >
ClassDefaultValues.minutesDelayToMakeNewChartAnalytics;
}
// class ListTotals {
// String listName;
// ConstructUses vocabUse;
// ListTotals({required this.listName, required this.vocabUse});
// }

@ -1,133 +1,312 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/base_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/construct_analytics_event.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/constructs_event.dart';
import 'package:fluffychat/pangea/models/constructs_model.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/models/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/summary_analytics_model.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../extensions/client_extension.dart';
import '../extensions/pangea_room_extension.dart';
import '../models/constructs_analytics_model.dart';
import '../models/student_analytics_event.dart';
class MyAnalyticsController {
class MyAnalyticsController extends BaseController {
late PangeaController _pangeaController;
MyAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;
}
String? get _userId => _pangeaController.matrixState.client.userID;
final List<String> analyticsEventTypes = [
PangeaEventTypes.summaryAnalytics,
PangeaEventTypes.construct,
];
//PTODO - locally cache and update periodically
Future<void> handleMessage(
Room room,
RecentMessageRecord messageRecord, {
bool isEdit = false,
}) async {
try {
debugPrint("in handle message with type ${messageRecord.useType}");
if (_userId == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "null userId in updateAnalytics",
s: StackTrace.current,
);
return;
}
Future<void> sendAllAnalyticsEvents(
Room analyticsRoom,
) async {
final String? langCode = analyticsRoom.madeForLang;
if (langCode == null) {
debugPrint("no lang code found for analytics room: ${analyticsRoom.id}");
return;
}
await _pangeaController.classController.addDirectChatsToClasses(room);
//expanding this to all parents of the room
// final List<Room> spaces = room.immediateClassParents;
final List<Room> spaces = room.pangeaSpaceParents;
// janky but probably stays until we have a class analytics bot added by
// default to all chats
final Map<String, AnalyticsEvent?> prevEvents = {};
for (final type in analyticsEventTypes) {
final prevEvent = await analyticsRoom.getLastAnalyticsEvent(type);
prevEvents[type] = prevEvent;
}
final List<DateTime?> lastUpdates = prevEvents.values
.map((e) => e?.content.lastUpdated)
.cast<DateTime?>()
.toList();
DateTime? earliestLastUpdated;
if (!lastUpdates.any((updated) => updated == null)) {
earliestLastUpdated = lastUpdates.reduce(
(min, e) => e!.isBefore(min!) ? e : min,
);
}
final List<RecentMessageRecord> analyticsContent = [];
final List<OneConstructUse> constructsContent = [];
for (final Room chat in _studentChats) {
final String? chatLangCode =
_pangeaController.languageController.activeL2Code(roomID: chat.id);
if (chatLangCode != langCode) continue;
final List<PangeaMessageEvent> recentMsgs =
await chat.myMessageEventsInChat(
since: earliestLastUpdated,
);
analyticsContent.addAll(
formatAnalyticsContent(
recentMsgs,
prevEvents[PangeaEventTypes.summaryAnalytics]
as SummaryAnalyticsEvent?,
),
);
constructsContent.addAll(
formatConstructsContent(
recentMsgs,
prevEvents[PangeaEventTypes.construct] as ConstructAnalyticsEvent?,
),
);
}
final List<StudentAnalyticsEvent?> events = await analyticsEvents(spaces);
if (analyticsContent.isNotEmpty) {
final SummaryAnalyticsModel analyticsModel = SummaryAnalyticsModel(
messages: analyticsContent,
lastUpdated: DateTime.now(),
prevEventId:
prevEvents[PangeaEventTypes.summaryAnalytics]?.event.eventId,
prevLastUpdated:
prevEvents[PangeaEventTypes.summaryAnalytics]?.content.lastUpdated,
);
await analyticsRoom.sendEvent(
analyticsModel.toJson(),
type: PangeaEventTypes.summaryAnalytics,
);
}
for (final event in events) {
if (event != null) {
event.handleNewMessage(messageRecord, isEdit: isEdit);
if (constructsContent.isNotEmpty) {
final Map<String, List<OneConstructUse>> lemmasUses = {};
for (final use in constructsContent) {
if (use.lemma == null) {
debugPrint("use has no lemma!");
continue;
}
lemmasUses[use.lemma!] ??= [];
lemmasUses[use.lemma]!.add(use);
}
} catch (err) {
debugger(when: kDebugMode);
final ConstructAnalyticsModel constructsModel = ConstructAnalyticsModel(
type: ConstructType.grammar,
uses: lemmasUses.entries
.map(
(entry) => LemmaConstructsModel(
lemma: entry.key,
uses: entry.value,
),
)
.toList(),
lastUpdated: DateTime.now(),
prevEventId: prevEvents[PangeaEventTypes.construct]?.event.eventId,
prevLastUpdated:
prevEvents[PangeaEventTypes.construct]?.content.lastUpdated,
);
await analyticsRoom.sendEvent(
constructsModel.toJson(),
type: PangeaEventTypes.construct,
);
}
}
Future<List<StudentAnalyticsEvent?>> analyticsEvents(
List<Room> spaces,
) async {
final List<Future<StudentAnalyticsEvent?>> events = [];
if (_userId != null) {
for (final space in spaces) {
events.add(space.getStudentAnalytics(_userId!));
}
List<RecentMessageRecord> formatAnalyticsContent(
List<PangeaMessageEvent> recentMsgs,
SummaryAnalyticsEvent? prevEvent,
) {
List<PangeaMessageEvent> filtered = List.from(recentMsgs);
if (prevEvent?.content.lastUpdated != null) {
filtered = recentMsgs
.where(
(msg) => msg.event.originServerTs.isAfter(
prevEvent!.content.lastUpdated!,
),
)
.toList();
}
final List<String> addedMsgIds =
prevEvent?.content.messages.map((msg) => msg.eventId).toList() ?? [];
filtered.removeWhere((msg) => addedMsgIds.contains(msg.eventId));
final List<RecentMessageRecord> records = filtered
.map(
(msg) => RecentMessageRecord(
eventId: msg.eventId,
chatId: msg.room.id,
useType: msg.useType,
time: msg.originServerTs,
),
)
.toList();
return records;
}
List<OneConstructUse> formatConstructsContent(
List<PangeaMessageEvent> recentMsgs,
ConstructAnalyticsEvent? prevEvent,
) {
List<PangeaMessageEvent> filtered = List.from(recentMsgs);
if (prevEvent?.content.lastUpdated != null) {
filtered = recentMsgs
.where(
(msg) => msg.event.originServerTs.isAfter(
prevEvent!.content.lastUpdated!,
),
)
.toList();
}
return Future.wait(events);
final List<String> addedMsgIds = prevEvent?.content.uses
.map((lemmause) => lemmause.uses.map((use) => use.msgId))
.expand((element) => element)
.where((element) => element != null)
.cast<String>()
.toList() ??
[];
filtered.removeWhere((msg) => addedMsgIds.contains(msg.eventId));
final List<OneConstructUse> uses = filtered
.map(
(msg) => msg.originalSent?.choreo?.toGrammarConstructUse(
msg.eventId,
msg.room.id,
msg.originServerTs,
),
)
.where((element) => element != null)
.cast<List<OneConstructUse>>()
.expand((element) => element)
.toList();
return uses;
}
Future<List<StudentAnalyticsEvent?>> allMyAnalyticsEvents() async =>
analyticsEvents(
await _pangeaController
.matrixState.client.classesAndExchangesImStudyingIn,
);
List<Room> _studentChats = [];
Future<void> setStudentChats() async {
final List<String> teacherRoomIds =
await _pangeaController.matrixState.client.teacherRoomIds;
_studentChats = _pangeaController.matrixState.client.rooms
.where(
(r) =>
!r.isSpace &&
!r.isAnalyticsRoom &&
!teacherRoomIds.contains(r.id),
)
.toList();
setState(data: _studentChats);
}
Future<void> saveConstructsMixed(
List<OneConstructUse> allUses,
String langCode, {
bool isEdit = false,
}) async {
List<Room> get studentChats {
try {
final Map<String, List<OneConstructUse>> aggregatedVocabUse = {};
for (final use in allUses) {
if (use.lemma == null) continue;
aggregatedVocabUse[use.lemma!] ??= [];
aggregatedVocabUse[use.lemma]!.add(use);
}
final Room analyticsRoom = await _pangeaController.matrixState.client
.getMyAnalyticsRoom(langCode);
final List<Future<void>> saveFutures = [];
for (final uses in aggregatedVocabUse.entries) {
debugPrint("saving of type ${uses.value.first.constructType}");
saveFutures.add(
analyticsRoom.saveConstructUsesSameLemma(
uses.key,
uses.value.first.constructType ?? ConstructType.grammar,
uses.value,
isEdit: isEdit,
),
);
}
if (_studentChats.isNotEmpty) return _studentChats;
setStudentChats();
return _studentChats;
} catch (err) {
debugger(when: kDebugMode);
return [];
}
}
await Future.wait(saveFutures);
} catch (err, s) {
List<Room> _studentSpaces = [];
Future<void> setStudentSpaces() async {
if (_studentSpaces.isNotEmpty) return;
_studentSpaces = await _pangeaController
.matrixState.client.classesAndExchangesImStudyingIn;
}
List<Room> get studentSpaces {
try {
if (_studentSpaces.isNotEmpty) return _studentSpaces;
setStudentSpaces();
return _studentSpaces;
} catch (err) {
debugger(when: kDebugMode);
if (!kDebugMode) rethrow;
ErrorHandler.logError(e: err, s: s);
return [];
}
}
// on the off chance that the user is in a class but doesn't have an analytics
// room for the target language of that class, create the analytics room(s)
Future<List<Room>> createMissingAnalyticsRoom() async {
List<String> targetLangs = [];
final String? userL2 = _pangeaController.languageController.activeL2Code();
if (userL2 != null) targetLangs.add(userL2);
final List<String?> spaceL2s = studentSpaces
.map(
(space) => _pangeaController.languageController.activeL2Code(
roomID: space.id,
),
)
.toList();
targetLangs.addAll(spaceL2s.where((l2) => l2 != null).cast<String>());
targetLangs = targetLangs.toSet().toList();
for (final String langCode in targetLangs) {
await _pangeaController.matrixState.client.getMyAnalyticsRoom(langCode);
}
return _pangeaController.matrixState.client.allMyAnalyticsRooms;
}
Future<void> updateAnalytics() async {
await setStudentChats();
final List<Room> analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
analyticsRooms.addAll(await createMissingAnalyticsRoom());
for (final Room analyticsRoom in analyticsRooms) {
await sendAllAnalyticsEvents(analyticsRoom);
}
}
// used to aggregate ConstructEvents, from multiple senders (students) with the same lemma
List<AggregateConstructUses> aggregateConstructData(
List<ConstructEvent> constructs,
List<ConstructAnalyticsEvent> constructs,
) {
final Map<String, List<ConstructEvent>> lemmasToConstructs = {};
final Map<String, List<LemmaConstructsModel>> lemmasToConstructs = {};
for (final construct in constructs) {
lemmasToConstructs[construct.content.lemma] ??= [];
lemmasToConstructs[construct.content.lemma]!.add(construct);
for (final lemmaUses in construct.content.uses) {
lemmasToConstructs[lemmaUses.lemma] ??= [];
lemmasToConstructs[lemmaUses.lemma]!.add(lemmaUses);
}
}
final List<AggregateConstructUses> aggregatedConstructs = [];
for (final lemmaToConstructs in lemmasToConstructs.entries) {
final List<ConstructEvent> lemmaConstructs = lemmaToConstructs.value;
final List<LemmaConstructsModel> lemmaConstructs =
lemmaToConstructs.value;
final AggregateConstructUses aggregatedData = AggregateConstructUses(
constructs: lemmaConstructs,
lemmaUses: lemmaConstructs,
);
aggregatedConstructs.add(aggregatedData);
}
@ -136,24 +315,23 @@ class MyAnalyticsController {
}
class AggregateConstructUses {
final List<ConstructEvent> _constructs;
final List<LemmaConstructsModel> _lemmaUses;
AggregateConstructUses({required List<ConstructEvent> constructs})
: _constructs = constructs;
AggregateConstructUses({required List<LemmaConstructsModel> lemmaUses})
: _lemmaUses = lemmaUses;
String get lemma {
assert(
_constructs.isNotEmpty &&
_constructs.every(
(construct) =>
construct.content.lemma == _constructs.first.content.lemma,
_lemmaUses.isNotEmpty &&
_lemmaUses.every(
(construct) => construct.lemma == _lemmaUses.first.lemma,
),
);
return _constructs.first.content.lemma;
return _lemmaUses.first.lemma;
}
List<OneConstructUse> get uses => _constructs
.map((construct) => construct.content.uses)
List<OneConstructUse> get uses => _lemmaUses
.map((lemmaUse) => lemmaUse.uses)
.expand((element) => element)
.toList();
}

@ -1,5 +1,3 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
@ -11,8 +9,6 @@ import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
import '../utils/p_store.dart';
extension PangeaClient on Client {
List<Room> get classes => rooms.where((e) => e.isPangeaClass).toList();
@ -97,23 +93,6 @@ extension PangeaClient on Client {
return teachers;
}
Future<void> updateMyLearningAnalyticsForAllClassesImIn([
PLocalStore? storageService,
]) async {
try {
final List<Future<void>> updateFutures = [];
for (final classRoom in classesAndExchangesImIn) {
updateFutures
.add(classRoom.updateMyLearningAnalyticsForClass(storageService));
}
await Future.wait(updateFutures);
} catch (err, s) {
if (kDebugMode) rethrow;
// debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
}
}
// get analytics room matching targetlanguage
// if not present, create it and invite teachers of that language
// set description to let people know what the hell it is
@ -143,7 +122,7 @@ extension PangeaClient on Client {
});
if (analyticsRoom != null &&
analyticsRoom.membership == Membership.invite) {
debugger(when: kDebugMode);
// debugger(when: kDebugMode);
analyticsRoom
.join()
.onError(

@ -6,8 +6,11 @@ import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/bot_options_model.dart';
import 'package:fluffychat/pangea/models/class_model.dart';
import 'package:fluffychat/pangea/models/constructs_event.dart';
import 'package:fluffychat/pangea/models/summary_analytics_event.dart';
import 'package:fluffychat/pangea/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
@ -22,15 +25,9 @@ import 'package:sentry_flutter/sentry_flutter.dart';
import '../../config/app_config.dart';
import '../constants/pangea_event_types.dart';
import '../enum/construct_type_enum.dart';
import '../enum/use_type.dart';
import '../matrix_event_wrappers/construct_analytics_event.dart';
import '../models/choreo_record.dart';
import '../models/constructs_analytics_model.dart';
import '../models/representation_content_model.dart';
import '../models/student_analytics_event.dart';
import '../models/student_analytics_summary_model.dart';
import '../utils/p_store.dart';
import 'client_extension.dart';
extension PangeaRoom on Room {
@ -303,6 +300,12 @@ extension PangeaRoom on Room {
bool isMadeByUser(String userId) =>
getState(EventTypes.RoomCreate)?.senderId == userId;
String? get madeForLang {
final creationContent = getState(EventTypes.RoomCreate)?.content;
return creationContent?.tryGet<String>(ModelKey.langCode) ??
creationContent?.tryGet<String>(ModelKey.oldLangCode);
}
bool isMadeForLang(String langCode) {
final creationContent = getState(EventTypes.RoomCreate)?.content;
return creationContent?.tryGet<String>(ModelKey.langCode) == langCode ||
@ -328,55 +331,81 @@ extension PangeaRoom on Room {
return canonicalAlias.replaceAll(":$domainString", "").replaceAll("#", "");
}
StudentAnalyticsEvent? _getStudentAnalyticsLocal(String studentId) {
if (!isSpace) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "calling getStudentAnalyticsLocal on non-space room",
s: StackTrace.current,
);
return null;
}
final Event? matrixEvent = getState(
PangeaEventTypes.studentAnalyticsSummary,
studentId,
);
return matrixEvent != null
? StudentAnalyticsEvent(event: matrixEvent)
: null;
}
Future<StudentAnalyticsEvent?> getStudentAnalytics(
String studentId, {
bool forcedUpdate = false,
}) async {
try {
if (!isSpace) {
debugger(when: kDebugMode);
throw Exception("calling getStudentAnalyticsLocal on non-space room");
}
StudentAnalyticsEvent? localEvent = _getStudentAnalyticsLocal(studentId);
if (localEvent == null) {
await postLoad();
localEvent = _getStudentAnalyticsLocal(studentId);
}
if (studentId == client.userID && localEvent == null) {
final Event? matrixEvent = await _createStudentAnalyticsEvent();
if (matrixEvent != null) {
localEvent = StudentAnalyticsEvent(event: matrixEvent);
}
}
return localEvent;
} catch (err) {
debugger(when: kDebugMode);
rethrow;
}
}
// StudentAnalyticsEvent? _getStudentAnalyticsLocal() {
// if (!isAnalyticsRoom) {
// debugger(when: kDebugMode);
// ErrorHandler.logError(
// m: "calling getStudentAnalyticsLocal on non-analytics room",
// s: StackTrace.current,
// );
// return null;
// }
// final Event? matrixEvent = getState(PangeaEventTypes.summaryAnalytics);
// return matrixEvent != null
// ? StudentAnalyticsEvent(event: matrixEvent)
// : null;
// }
// Future<Map<String, DateTime>> lemmasLastUpdated() async {
// try {
// if (!isAnalyticsRoom) {
// debugger(when: kDebugMode);
// throw Exception(
// "calling lemmasLastUpdated on non-analytics room",
// );
// }
// await postLoad();
// final entries = states[PangeaEventTypes.vocab]?.entries.toList();
// if (entries != null && entries.isNotEmpty) {
// final Map<String, DateTime> resultMap = {};
// for (final entry in entries) {
// // migration - don't count uses without unique IDs
// if (ConstructEvent(event: entry.value)
// .content
// .uses
// .any((use) => use.id != null)) {
// resultMap[entry.key] = entry.value.originServerTs;
// }
// }
// return resultMap;
// }
// return {};
// } catch (err) {
// debugger(when: kDebugMode);
// rethrow;
// }
// }
// Future<StudentAnalyticsEvent?> getStudentAnalyticsEvent({
// bool forcedUpdate = false,
// }) async {
// try {
// if (!isAnalyticsRoom) {
// debugger(when: kDebugMode);
// throw Exception(
// "calling getStudentAnalyticsLocal on non-analytics room",
// );
// }
// await postLoad();
// StudentAnalyticsEvent? localEvent = _getStudentAnalyticsLocal();
// if (isRoomOwner && localEvent == null) {
// final Event? matrixEvent = await _createStudentAnalyticsEvent();
// if (matrixEvent != null) {
// localEvent = StudentAnalyticsEvent(event: matrixEvent);
// }
// }
// return localEvent;
// } catch (err) {
// debugger(when: kDebugMode);
// rethrow;
// }
// }
void checkClass() {
if (!isSpace) {
@ -414,201 +443,109 @@ extension PangeaRoom on Room {
: participants;
}
/// if [studentIds] is null, returns all students
Future<List<StudentAnalyticsEvent?>> getClassAnalytics([
List<String>? studentIds,
]) async {
await postLoad();
await requestParticipants();
final List<Future<StudentAnalyticsEvent?>> sassFutures = [];
final List<String> filteredIds = students
.where(
(element) => studentIds == null || studentIds.contains(element.id),
)
.map((e) => e.id)
.toList();
for (final id in filteredIds) {
sassFutures.add(
getStudentAnalytics(
id,
),
);
}
return Future.wait(sassFutures);
}
/// if [isSpace]
/// for all child chats, call _getChatAnalyticsGlobal and merge results
/// else
/// get analytics from pangea chat server
/// do any needed conversion work
/// save RoomAnalytics object to PangeaEventTypes.analyticsSummary event
Future<Event?> _createStudentAnalyticsEvent() async {
try {
await postLoad();
if (!pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) {
ErrorHandler.logError(
m: "null powerLevels in createStudentAnalytics",
s: StackTrace.current,
);
return null;
}
if (client.userID == null) {
debugger(when: kDebugMode);
throw Exception("null userId in createStudentAnalytics");
}
final String eventId = await client.setRoomStateWithKey(
id,
PangeaEventTypes.studentAnalyticsSummary,
client.userID!,
StudentAnalyticsSummary(
// studentId: client.userID!,
lastUpdated: DateTime.now(),
messages: [],
).toJson(),
);
final Event? event = await getEventById(eventId);
if (event == null) {
debugger(when: kDebugMode);
throw Exception(
"null event after creation with eventId $eventId in createStudentAnalytics",
);
}
return event;
} catch (err, stack) {
ErrorHandler.logError(e: err, s: stack, data: powerLevels);
return null;
}
}
/// for each chat in class
/// get timeline back to january 15
/// get messages
/// discard timeline
/// save messages to StudentAnalyticsSummary
Future<void> updateMyLearningAnalyticsForClass([
PLocalStore? storageService,
]) async {
try {
final String migratedAnalyticsKey =
"MIGRATED_ANALYTICS_KEY${id.localpart}";
if (storageService?.read(
migratedAnalyticsKey,
local: true,
) ??
false) return;
if (!isPangeaClass && !isExchange) {
throw Exception(
"In updateMyLearningAnalyticsForClass with room that is not not a class",
);
}
if (client.userID == null) {
debugger(when: kDebugMode);
return;
}
final StudentAnalyticsEvent? myAnalEvent =
await getStudentAnalytics(client.userID!);
if (myAnalEvent == null) {
debugPrint("null analytcs event for $id");
if (pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) {
// debugger(when: kDebugMode);
}
return;
}
final updateMessages = await _messageListForAllChildChats;
updateMessages.removeWhere(
(element) => myAnalEvent.content.messages.any(
(e) => e.eventId == element.eventId,
),
);
myAnalEvent.bulkUpdate(updateMessages);
await storageService?.save(
migratedAnalyticsKey,
true,
local: true,
);
} catch (err, s) {
if (kDebugMode) rethrow;
// debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: s);
}
}
Future<List<RecentMessageRecord>> get _messageListForAllChildChats async {
try {
if (!isSpace) return [];
final List<Room> spaceChats = spaceChildren
.where((e) => e.roomId != null)
.map((e) => client.getRoomById(e.roomId!))
.where((element) => element != null)
.cast<Room>()
.where((element) => !element.isSpace)
.toList();
final List<Future<List<RecentMessageRecord>>> msgListFutures = [];
for (final chat in spaceChats) {
msgListFutures.add(chat._messageListForChat);
}
final List<List<RecentMessageRecord>> msgLists =
await Future.wait(msgListFutures);
final List<RecentMessageRecord> joined = [];
for (final msgList in msgLists) {
joined.addAll(msgList);
}
return joined;
} catch (err) {
// debugger(when: kDebugMode);
rethrow;
}
}
Future<List<RecentMessageRecord>> get _messageListForChat async {
// Future<Event?> _createStudentAnalyticsEvent() async {
// try {
// if (!isAnalyticsRoom) {
// debugger(when: kDebugMode);
// throw Exception(
// "calling _createStudentAnalyticsEvent on non-analytics room",
// );
// }
// await postLoad();
// if (!pangeaCanSendEvent(PangeaEventTypes.studentAnalyticsSummary)) {
// ErrorHandler.logError(
// m: "null powerLevels in createStudentAnalytics",
// s: StackTrace.current,
// );
// return null;
// }
// if (client.userID == null) {
// debugger(when: kDebugMode);
// throw Exception("null userId in createStudentAnalytics");
// }
// final String eventId = await client.setRoomStateWithKey(
// id,
// PangeaEventTypes.studentAnalyticsSummary,
// '',
// StudentAnalyticsSummary(
// lastUpdated: null,
// messages: [],
// ).toJson(),
// );
// final Event? event = await getEventById(eventId);
// if (event == null) {
// debugger(when: kDebugMode);
// throw Exception(
// "null event after creation with eventId $eventId in createStudentAnalytics",
// );
// }
// return event;
// } catch (err, stack) {
// ErrorHandler.logError(e: err, s: stack, data: powerLevels);
// return null;
// }
// }
Future<List<PangeaMessageEvent>> myMessageEventsInChat({
DateTime? since,
}) async {
try {
int numberOfSearches = 0;
if (isSpace) {
throw Exception(
"In messageListForChat with room that is not a chat",
);
}
final Timeline timeline = await getTimeline();
while (timeline.canRequestHistory && numberOfSearches < 50) {
await timeline.requestHistory(historyCount: 100);
try {
await timeline.requestHistory();
} catch (err) {
break;
}
numberOfSearches += 1;
}
if (timeline.canRequestHistory) {
debugger(when: kDebugMode);
if (timeline.events.any(
(event) => event.originServerTs.isAfter(since ?? DateTime.now()),
)) {
break;
}
}
final List<RecentMessageRecord> msgs = [];
for (final event in timeline.events) {
if (event.senderId == client.userID &&
event.type == EventTypes.Message &&
event.content['msgtype'] == MessageTypes.Text) {
final List<PangeaMessageEvent> msgs = [];
for (Event event in timeline.events) {
final bool hasAnalytics = (event.senderId == client.userID) &&
(event.type == EventTypes.Message) &&
(event.content['msgtype'] == MessageTypes.Text &&
!(event.relationshipType == RelationshipTypes.edit));
if (hasAnalytics &&
(since == null || event.originServerTs.isAfter(since))) {
if (event.hasAggregatedEvents(timeline, RelationshipTypes.edit)) {
event = event
.aggregatedEvents(
timeline,
RelationshipTypes.edit,
)
.sorted(
(a, b) => b.originServerTs.compareTo(a.originServerTs),
)
.firstOrNull ??
event;
}
final PangeaMessageEvent pMsgEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: true,
);
msgs.add(
RecentMessageRecord(
eventId: event.eventId,
chatId: id,
useType: pMsgEvent.useType,
time: event.originServerTs,
),
);
msgs.add(pMsgEvent);
}
}
return msgs;
@ -673,139 +610,152 @@ extension PangeaRoom on Room {
}
}
ConstructEvent? _vocabEventLocal(String lemma) {
if (!isAnalyticsRoom) throw Exception("not an analytics room");
// ConstructEvent? vocabEventLocal(String lemma) {
// if (!isAnalyticsRoom) throw Exception("not an analytics room");
final Event? matrixEvent = getState(PangeaEventTypes.vocab, lemma);
// final Event? matrixEvent = getState(PangeaEventTypes.vocab, lemma);
return matrixEvent != null ? ConstructEvent(event: matrixEvent) : null;
}
// return matrixEvent != null ? ConstructEvent(event: matrixEvent) : null;
// }
bool get isRoomOwner =>
getState(EventTypes.RoomCreate)?.senderId == client.userID;
Future<ConstructEvent> vocabEvent(
String lemma,
ConstructType type, [
bool makeIfNull = false,
]) async {
try {
if (!isAnalyticsRoom) throw Exception("not an analytics room");
ConstructEvent? localEvent = _vocabEventLocal(lemma);
if (localEvent != null) return localEvent;
await postLoad();
localEvent = _vocabEventLocal(lemma);
if (localEvent == null && isRoomOwner && makeIfNull) {
final Event matrixEvent = await _createVocabEvent(lemma, type);
localEvent = ConstructEvent(event: matrixEvent);
}
return localEvent!;
} catch (err) {
debugger(when: kDebugMode);
rethrow;
}
}
Future<List<OneConstructUse>> removeEdittedLemmas(
List<OneConstructUse> lemmaUses,
) async {
final List<String> removeUses = [];
for (final use in lemmaUses) {
if (use.msgId == null) continue;
final List<String> removeIds = await client.getEditHistory(
use.chatId,
use.msgId!,
);
removeUses.addAll(removeIds);
}
lemmaUses.removeWhere((use) => removeUses.contains(use.msgId));
final allEvents = await allConstructEvents;
for (final constructEvent in allEvents) {
await constructEvent.removeEdittedUses(removeUses, client);
}
return lemmaUses;
}
Future<void> saveConstructUsesSameLemma(
String lemma,
ConstructType type,
List<OneConstructUse> lemmaUses, {
bool isEdit = false,
}) async {
final ConstructEvent? localEvent = _vocabEventLocal(lemma);
if (isEdit) {
lemmaUses = await removeEdittedLemmas(lemmaUses);
}
if (localEvent == null) {
await client.setRoomStateWithKey(
id,
PangeaEventTypes.vocab,
lemma,
ConstructUses(lemma: lemma, type: type, uses: lemmaUses).toJson(),
);
} else {
localEvent.addAll(lemmaUses);
await updateStateEvent(localEvent.event);
}
}
Future<List<ConstructEvent>> get allConstructEvents async {
await postLoad();
return states[PangeaEventTypes.vocab]
?.values
.map((Event event) => ConstructEvent(event: event))
.toList()
.cast<ConstructEvent>() ??
[];
}
Future<Event> _createVocabEvent(String lemma, ConstructType type) async {
try {
if (!isRoomOwner) {
throw Exception(
"Tried to create vocab event in room where user is not owner",
);
}
final String eventId = await client.setRoomStateWithKey(
id,
PangeaEventTypes.vocab,
lemma,
ConstructUses(lemma: lemma, type: type).toJson(),
);
final Event? event = await getEventById(eventId);
if (event == null) {
debugger(when: kDebugMode);
throw Exception(
"null event after creation with eventId $eventId in _createVocabEvent",
);
}
return event;
} catch (err, stack) {
debugger(when: kDebugMode);
ErrorHandler.logError(e: err, s: stack, data: powerLevels);
rethrow;
}
}
// Future<ConstructEvent> vocabEvent(
// String lemma,
// ConstructType type, [
// bool makeIfNull = false,
// ]) async {
// try {
// if (!isAnalyticsRoom) throw Exception("not an analytics room");
// ConstructEvent? localEvent = vocabEventLocal(lemma);
// if (localEvent != null) return localEvent;
// await postLoad();
// localEvent = vocabEventLocal(lemma);
// if (localEvent == null && isRoomOwner && makeIfNull) {
// final Event matrixEvent = await _createVocabEvent(lemma, type);
// localEvent = ConstructEvent(event: matrixEvent);
// }
// return localEvent!;
// } catch (err) {
// debugger(when: kDebugMode);
// rethrow;
// }
// }
// Future<List<OneConstructUse>> removeEdittedLemmas(
// List<OneConstructUse> lemmaUses,
// ) async {
// final List<String> removeUses = [];
// for (final use in lemmaUses) {
// if (use.msgId == null) continue;
// final List<String> removeIds = await client.getEditHistory(
// use.chatId,
// use.msgId!,
// );
// removeUses.addAll(removeIds);
// }
// lemmaUses.removeWhere((use) => removeUses.contains(use.msgId));
// final allEvents = await allConstructEvents;
// for (final constructEvent in allEvents) {
// await constructEvent.removeEdittedUses(removeUses, client);
// }
// return lemmaUses;
// }
// Future<void> saveConstructUsesSameLemma(
// String lemma,
// ConstructType type,
// List<OneConstructUse> lemmaUses, {
// bool isEdit = false,
// }) async {
// final ConstructEvent? localEvent = vocabEventLocal(lemma);
// if (isEdit) {
// lemmaUses = await removeEdittedLemmas(lemmaUses);
// }
// // final waitForUpdate = client.onRoomState.stream.firstWhere(
// // (Event event) =>
// // event.type == PangeaEventTypes.vocab && event.stateKey == lemma,
// // );
// if (localEvent == null) {
// await client.setRoomStateWithKey(
// id,
// PangeaEventTypes.vocab,
// lemma,
// ConstructUses(lemma: lemma, type: type, uses: lemmaUses).toJson(),
// );
// await postLoad();
// } else {
// // migration - remove uses without unique IDs,
// // this is used to prevent duplicate saves
// localEvent.content.uses.removeWhere((use) => use.id == null);
// localEvent.addAll(lemmaUses);
// await updateStateEvent(localEvent.event);
// }
// // await waitForUpdate;
// }
// Future<List<ConstructEvent>> get allConstructEvents async {
// await postLoad();
// return states[PangeaEventTypes.vocab]
// ?.values
// .map((Event event) => ConstructEvent(event: event))
// .toList()
// .cast<ConstructEvent>() ??
// [];
// }
// Future<Event> _createVocabEvent(String lemma, ConstructType type) async {
// try {
// if (!isRoomOwner) {
// throw Exception(
// "Tried to create vocab event in room where user is not owner",
// );
// }
// final String eventId = await client.setRoomStateWithKey(
// id,
// PangeaEventTypes.vocab,
// lemma,
// ConstructUses(lemma: lemma, type: type).toJson(),
// );
// final Event? event = await getEventById(eventId);
// if (event == null) {
// debugger(when: kDebugMode);
// throw Exception(
// "null event after creation with eventId $eventId in _createVocabEvent",
// );
// }
// return event;
// } catch (err, stack) {
// debugger(when: kDebugMode);
// ErrorHandler.logError(e: err, s: stack, data: powerLevels);
// rethrow;
// }
// }
/// update state event and return eventId
Future<String> updateStateEvent(Event stateEvent) {
Future<String> updateStateEvent(Event stateEvent) async {
if (stateEvent.stateKey == null) {
throw Exception("stateEvent.stateKey is null");
}
return client.setRoomStateWithKey(
final String resp = await client.setRoomStateWithKey(
id,
stateEvent.type,
stateEvent.stateKey!,
stateEvent.content,
);
await postLoad();
return resp;
}
bool canIAddSpaceChild(Room? room) {
@ -872,15 +822,9 @@ extension PangeaRoom on Room {
final Map<String, dynamic>? currentPowerContent =
currentPower?.content["events"] as Map<String, dynamic>?;
final spaceChildPower = currentPowerContent?[EventTypes.spaceChild];
final studentAnalyticsPower =
currentPowerContent?[PangeaEventTypes.studentAnalyticsSummary];
if ((spaceChildPower == null || studentAnalyticsPower == null) &&
currentPowerContent != null) {
if (spaceChildPower == null && currentPowerContent != null) {
currentPowerContent["events"][EventTypes.spaceChild] = 0;
currentPowerContent["events"]
[PangeaEventTypes.studentAnalyticsSummary] = 0;
await client.setRoomStateWithKey(
id,
EventTypes.RoomPowerLevels,
@ -1260,4 +1204,83 @@ extension PangeaRoom on Room {
if (firstLanguageSettings?.targetLanguage == null) return;
await client.getMyAnalyticsRoom(firstLanguageSettings!.targetLanguage);
}
Future<AnalyticsEvent?> getLastAnalyticsEvent(
String type,
) async {
final Timeline timeline = await getTimeline();
int requests = 0;
Event? lastEvent = timeline.events.firstWhereOrNull(
(event) => event.type == type,
);
while (requests < 10 && timeline.canRequestHistory && lastEvent == null) {
await timeline.requestHistory();
lastEvent = timeline.events.firstWhereOrNull(
(event) => event.type == type,
);
requests++;
}
if (lastEvent == null) return null;
switch (type) {
case PangeaEventTypes.summaryAnalytics:
return SummaryAnalyticsEvent(event: lastEvent);
case PangeaEventTypes.construct:
return ConstructAnalyticsEvent(event: lastEvent);
}
return null;
}
Future<AnalyticsEvent?> getPrevAnalyticsEvent(
AnalyticsEvent analyticsEvent,
) async {
if (analyticsEvent.content.prevEventId == null) {
return null;
}
final Event? prevEvent = await getEventById(
analyticsEvent.content.prevEventId!,
);
if (prevEvent == null) return null;
switch (analyticsEvent.event.type) {
case PangeaEventTypes.summaryAnalytics:
return SummaryAnalyticsEvent(event: prevEvent);
case PangeaEventTypes.construct:
return ConstructAnalyticsEvent(event: prevEvent);
}
return null;
// } catch (err) {
// debugger(when: kDebugMode);
// return null;
// }
}
Future<List<AnalyticsEvent>?> getAnalyticsEvents({
required String type,
DateTime? since,
}) async {
final AnalyticsEvent? mostRecentEvent = await getLastAnalyticsEvent(type);
if (mostRecentEvent == null) return null;
final List<AnalyticsEvent> events = [mostRecentEvent];
bool getAllEvents() =>
since == null && events.last.content.prevEventId == null;
bool reachedUpdated() =>
since != null &&
(events.last.content.lastUpdated?.isBefore(since) ?? true);
while (getAllEvents() || !reachedUpdated()) {
final AnalyticsEvent? prevEvent = await getPrevAnalyticsEvent(
events.last,
);
if (prevEvent == null) break;
events.add(prevEvent);
}
return events;
}
}

@ -1,53 +1,60 @@
import 'package:fluffychat/pangea/models/constructs_analytics_model.dart';
import 'package:matrix/matrix.dart';
// import 'package:fluffychat/pangea/models/constructs_analytics_model.dart';
// import 'package:flutter/material.dart';
// import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
// import '../constants/pangea_event_types.dart';
class ConstructEvent {
late Event _event;
ConstructUses? _contentCache;
// class ConstructEvent {
// late Event _event;
// ConstructUses? _contentCache;
ConstructEvent({required Event event}) {
if (event.type != PangeaEventTypes.vocab) {
throw Exception(
"${event.type} should not be used to make a StudentAnalyticsEvent",
);
}
_event = event;
}
// ConstructEvent({required Event event}) {
// if (event.type != PangeaEventTypes.vocab) {
// throw Exception(
// "${event.type} should not be used to make a StudentAnalyticsEvent",
// );
// }
// _event = event;
// }
Event get event => _event;
// Event get event => _event;
ConstructUses get content {
_contentCache ??= ConstructUses.fromJson(event.content);
if (_contentCache!.lemma.isEmpty) {
_contentCache!.lemma = event.stateKey!;
}
return _contentCache!;
}
// ConstructUses get content {
// _contentCache ??= ConstructUses.fromJson(event.content);
// if (_contentCache!.lemma.isEmpty) {
// _contentCache!.lemma = event.stateKey!;
// }
// return _contentCache!;
// }
void addAll(List<OneConstructUse> uses) {
content.uses.addAll(uses);
event.content = content.toJson();
}
// void addAll(List<OneConstructUse> uses) {
// for (final use in uses) {
// if (content.uses.any((element) => element.id == use.id)) {
// continue;
// }
// debugPrint("${use.toJson()}");
// content.uses.add(use);
// }
// event.content = content.toJson();
// }
Future<void> removeEdittedUses(
List<String> removeIds,
Client client,
) async {
_contentCache ??= ConstructUses.fromJson(event.content);
if (_contentCache == null || _event.stateKey == null) return;
final previousLength = _contentCache!.uses.length;
_contentCache!.uses.removeWhere(
(element) => removeIds.contains(element.msgId),
);
if (previousLength > _contentCache!.uses.length) {
await client.setRoomStateWithKey(
_event.room.id,
_event.type,
_event.stateKey!,
_contentCache!.toJson(),
);
}
}
}
// Future<void> removeEdittedUses(
// List<String> removeIds,
// Client client,
// ) async {
// _contentCache ??= ConstructUses.fromJson(event.content);
// if (_contentCache == null || _event.stateKey == null) return;
// final previousLength = _contentCache!.uses.length;
// _contentCache!.uses.removeWhere(
// (element) => removeIds.contains(element.msgId),
// );
// if (previousLength > _contentCache!.uses.length) {
// await client.setRoomStateWithKey(
// _event.room.id,
// _event.type,
// _event.stateKey!,
// _contentCache!.toJson(),
// );
// }
// }
// }

@ -0,0 +1,28 @@
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/models/analytics_model.dart';
import 'package:fluffychat/pangea/models/constructs_model.dart';
import 'package:fluffychat/pangea/models/summary_analytics_model.dart';
import 'package:matrix/matrix.dart';
abstract class AnalyticsEvent {
late Event _event;
AnalyticsModel? contentCache;
AnalyticsEvent({required Event event}) {
_event = event;
}
Event get event => _event;
AnalyticsModel get content {
switch (_event.type) {
case PangeaEventTypes.summaryAnalytics:
contentCache ??= SummaryAnalyticsModel.fromJson(event.content);
break;
case PangeaEventTypes.construct:
contentCache ??= ConstructAnalyticsModel.fromJson(event.content);
break;
}
return contentCache!;
}
}

@ -0,0 +1,11 @@
abstract class AnalyticsModel {
DateTime? lastUpdated;
String? prevEventId;
DateTime? prevLastUpdated;
AnalyticsModel({
this.lastUpdated,
this.prevEventId,
this.prevLastUpdated,
});
}

@ -2,6 +2,7 @@ import 'dart:convert';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/models/pangea_token_model.dart';
import '../constants/choreo_constants.dart';
import '../enum/construct_type_enum.dart';
import 'constructs_analytics_model.dart';
@ -210,9 +211,12 @@ class ChoreoRecord {
return uses;
}
List<OneConstructUse> toGrammarConstructUse(String msgId, String chatId) {
List<OneConstructUse> toGrammarConstructUse(
String msgId,
String chatId,
DateTime timestamp,
) {
final List<OneConstructUse> uses = [];
final DateTime now = DateTime.now();
for (final step in choreoSteps) {
if (step.acceptedOrIgnoredMatch?.status == PangeaMatchStatus.accepted) {
final String name = step.acceptedOrIgnoredMatch!.match.rule?.id ??
@ -222,11 +226,12 @@ class ChoreoRecord {
OneConstructUse(
useType: ConstructUseType.ga,
chatId: chatId,
timeStamp: now,
timeStamp: timestamp,
lemma: name,
form: name,
msgId: msgId,
constructType: ConstructType.grammar,
id: "${msgId}_${step.acceptedOrIgnoredMatch!.match.offset}_${step.acceptedOrIgnoredMatch!.match.length}",
),
);
}

@ -153,6 +153,7 @@ class OneConstructUse {
String chatId;
String? msgId;
DateTime timeStamp;
String? id;
OneConstructUse({
required this.useType,
@ -162,6 +163,7 @@ class OneConstructUse {
required this.form,
required this.msgId,
required this.constructType,
this.id,
});
factory OneConstructUse.fromJson(Map<String, dynamic> json) {
@ -176,6 +178,7 @@ class OneConstructUse {
constructType: json['constructType'] != null
? ConstructTypeUtil.fromString(json['constructType'])
: null,
id: json['id'],
);
}
@ -191,6 +194,7 @@ class OneConstructUse {
if (!condensed && constructType != null) {
data['constructType'] = constructType!.string;
}
if (id != null) data['id'] = id;
return data;
}

@ -0,0 +1,52 @@
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/constructs_model.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class ConstructAnalyticsEvent extends AnalyticsEvent {
ConstructAnalyticsEvent({required Event event}) : super(event: event) {
if (event.type != PangeaEventTypes.construct) {
throw Exception(
"${event.type} should not be used to make a ConstructAnalyticsEvent",
);
}
}
@override
ConstructAnalyticsModel get content {
contentCache ??= ConstructAnalyticsModel.fromJson(event.content);
return contentCache as ConstructAnalyticsModel;
}
// void addAll(List<OneConstructUse> uses) {
// for (final use in uses) {
// if (content.uses.any((element) => element.id == use.id)) {
// continue;
// }
// debugPrint("${use.toJson()}");
// content.uses.add(use);
// }
// event.content = content.toJson();
// }
// Future<void> removeEdittedUses(
// List<String> removeIds,
// Client client,
// ) async {
// _contentCache ??= ConstructUses.fromJson(event.content);
// if (_contentCache == null || _event.stateKey == null) return;
// final previousLength = _contentCache!.uses.length;
// _contentCache!.uses.removeWhere(
// (element) => removeIds.contains(element.msgId),
// );
// if (previousLength > _contentCache!.uses.length) {
// await client.setRoomStateWithKey(
// _event.room.id,
// _event.type,
// _event.stateKey!,
// _contentCache!.toJson(),
// );
// }
// }
}

@ -0,0 +1,257 @@
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/models/analytics_model.dart';
import 'package:fluffychat/pangea/models/constructs_analytics_model.dart';
import 'package:flutter/material.dart';
import '../enum/construct_type_enum.dart';
class ConstructAnalyticsModel extends AnalyticsModel {
ConstructType type;
List<LemmaConstructsModel> uses;
ConstructAnalyticsModel({
required this.type,
this.uses = const [],
super.lastUpdated,
super.prevEventId,
super.prevLastUpdated,
});
static const _lastUpdatedKey = "lupt";
static const _usesKey = "uses";
factory ConstructAnalyticsModel.fromJson(Map<String, dynamic> json) {
// try {
// debugger(
// when:
// kDebugMode && (json['uses'] == null || json[ModelKey.lemma] == null),
// );
return ConstructAnalyticsModel(
// lemma: json[ModelKey.lemma],
// uses: (json['uses'] as Iterable)
// .map<OneConstructUse?>(
// (use) => use != null ? OneConstructUse.fromJson(use) : null,
// )
// .where((element) => element != null)
// .cast<OneConstructUse>()
// .toList(),
type: ConstructTypeUtil.fromString(json['type']),
lastUpdated: json[_lastUpdatedKey] != null
? DateTime.parse(json[_lastUpdatedKey])
: null,
prevEventId: json[ModelKey.prevEventId],
prevLastUpdated: json[ModelKey.prevLastUpdated] != null
? DateTime.parse(json[ModelKey.prevLastUpdated])
: null,
uses: json[_usesKey]
.values
.map((lemmaUses) => LemmaConstructsModel.fromJson(lemmaUses))
.cast<LemmaConstructsModel>()
.toList(),
);
// } catch (err) {
// debugger(when: kDebugMode);
// rethrow;
// }
}
toJson() {
final Map<String, dynamic> usesMap = {};
for (final use in uses) {
debugPrint("use: $use");
usesMap[use.lemma] = use.toJson();
}
return {
// ModelKey.lemma: lemma,
// 'uses': uses.map((use) => use.toJson()).toList(),
'type': type.string,
_lastUpdatedKey: lastUpdated?.toIso8601String(),
_usesKey: usesMap,
ModelKey.prevEventId: prevEventId,
ModelKey.prevLastUpdated: prevLastUpdated?.toIso8601String(),
};
}
// void addUsesByUseType(List<OneConstructUse> uses) {
// for (final use in uses) {
// if (use.lemma != lemma) {
// throw Exception('lemma mismatch');
// }
// uses.add(use);
// }
// }
}
class LemmaConstructsModel {
String lemma;
List<OneConstructUse> uses;
LemmaConstructsModel({
required this.lemma,
this.uses = const [],
});
factory LemmaConstructsModel.fromJson(Map<String, dynamic> json) {
return LemmaConstructsModel(
lemma: json[ModelKey.lemma],
uses: (json['uses'] ?? [] as Iterable)
.map<OneConstructUse?>(
(use) => use != null ? OneConstructUse.fromJson(use) : null,
)
.where((element) => element != null)
.cast<OneConstructUse>()
.toList(),
);
}
Map<String, dynamic> toJson() {
return {
ModelKey.lemma: lemma,
'uses': uses.map((use) => use.toJson()).toList(),
};
}
}
enum ConstructUseType {
/// produced in chat by user, igc was run, and we've judged it to be a correct use
wa,
/// produced in chat by user, igc was run, and we've judged it to be a incorrect use
/// Note: if the IGC match is ignored, this is not counted as an incorrect use
ga,
/// produced in chat by user and igc was not run
unk,
/// selected correctly in IT flow
corIt,
/// encountered as IT distractor and correctly ignored it
ignIt,
/// encountered as it distractor and selected it
incIt,
/// encountered in igc match and ignored match
ignIGC,
/// selected correctly in IGC flow
corIGC,
/// encountered as distractor in IGC flow and selected it
incIGC,
}
extension on ConstructUseType {
String get string {
switch (this) {
case ConstructUseType.ga:
return 'ga';
case ConstructUseType.wa:
return 'wa';
case ConstructUseType.corIt:
return 'corIt';
case ConstructUseType.incIt:
return 'incIt';
case ConstructUseType.ignIt:
return 'ignIt';
case ConstructUseType.ignIGC:
return 'ignIGC';
case ConstructUseType.corIGC:
return 'corIGC';
case ConstructUseType.incIGC:
return 'incIGC';
case ConstructUseType.unk:
return 'unk';
}
}
IconData get icon {
switch (this) {
case ConstructUseType.ga:
return Icons.check;
case ConstructUseType.wa:
return Icons.thumb_up_sharp;
case ConstructUseType.corIt:
return Icons.check;
case ConstructUseType.incIt:
return Icons.close;
case ConstructUseType.ignIt:
return Icons.close;
case ConstructUseType.ignIGC:
return Icons.close;
case ConstructUseType.corIGC:
return Icons.check;
case ConstructUseType.incIGC:
return Icons.close;
case ConstructUseType.unk:
return Icons.help;
}
}
}
// class OneConstructUse {
// String? lemma;
// ConstructType? constructType;
// String? form;
// ConstructUseType useType;
// String chatId;
// String? msgId;
// DateTime timeStamp;
// String? id;
// OneConstructUse({
// required this.useType,
// required this.chatId,
// required this.timeStamp,
// required this.lemma,
// required this.form,
// required this.msgId,
// required this.constructType,
// this.id,
// });
// factory OneConstructUse.fromJson(Map<String, dynamic> json) {
// return OneConstructUse(
// useType: ConstructUseType.values
// .firstWhere((e) => e.string == json['useType']),
// chatId: json['chatId'],
// timeStamp: DateTime.parse(json['timeStamp']),
// lemma: json['lemma'],
// form: json['form'],
// msgId: json['msgId'],
// constructType: json['constructType'] != null
// ? ConstructTypeUtil.fromString(json['constructType'])
// : null,
// id: json['id'],
// );
// }
// Map<String, dynamic> toJson([bool condensed = true]) {
// final Map<String, String?> data = {
// 'useType': useType.string,
// 'chatId': chatId,
// 'timeStamp': timeStamp.toIso8601String(),
// 'form': form,
// 'msgId': msgId,
// };
// if (!condensed && lemma != null) data['lemma'] = lemma!;
// if (!condensed && constructType != null) {
// data['constructType'] = constructType!.string;
// }
// if (id != null) data['id'] = id;
// return data;
// }
// Room? getRoom(Client client) {
// return client.getRoomById(chatId);
// }
// Future<Event?> getEvent(Client client) async {
// final Room? room = getRoom(client);
// if (room == null || msgId == null) return null;
// return room.getEventById(msgId!);
// }
// }

@ -1,161 +1,164 @@
import 'dart:developer';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/extensions/client_extension.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_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';
import 'chart_analytics_model.dart';
class StudentAnalyticsEvent {
late Event _event;
StudentAnalyticsSummary? _contentCache;
List<RecentMessageRecord> _messagesToSave = [];
StudentAnalyticsEvent({required Event event}) {
if (event.type != PangeaEventTypes.studentAnalyticsSummary) {
throw Exception(
"${event.type} should not be used to make a StudentAnalyticsEvent",
);
}
_event = event;
if (!classRoom.isSpace) {
throw Exception(
"non-class room should not be used to make a StudentAnalyticsEvent",
);
}
_event = event;
_messagesToSave = [];
}
Room get classRoom => _event.room;
Event get event => _event;
StudentAnalyticsSummary get content {
_contentCache ??= StudentAnalyticsSummary.fromJson(event.content);
return _contentCache!;
}
Future<void> removeEdittedMessages(
RecentMessageRecord message,
) async {
final List<String> removeIds = await classRoom.client.getEditHistory(
message.chatId,
message.eventId,
);
if (removeIds.isEmpty) return;
_messagesToSave.removeWhere(
(msg) => removeIds.any((e) => e == msg.eventId),
);
content.removeEdittedMessages(
classRoom.client,
removeIds,
);
}
Future<void> handleNewMessage(
RecentMessageRecord message, {
isEdit = false,
}) async {
if (classRoom.client.userID != _event.stateKey) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "should not be in handleNewMessage ${classRoom.client.userID} != ${_event.stateKey}",
);
return;
}
if (isEdit) {
await removeEdittedMessages(message);
}
_addMessage(message);
if (DateTime.now().difference(content.lastUpdated).inMinutes >
ClassDefaultValues.minutesDelayToUpdateMyAnalytics) {
_updateStudentAnalytics();
}
}
Future<void> bulkUpdate(List<RecentMessageRecord> messages) async {
if (classRoom.client.userID != _event.stateKey) {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "should not be in bulkUpdate ${classRoom.client.userID} != ${_event.stateKey}",
);
return;
}
for (final message in messages) {
await removeEdittedMessages(message);
}
_messagesToSave.addAll(messages);
_updateStudentAnalytics();
}
Future<void> _updateStudentAnalytics() async {
content.lastUpdated = DateTime.now();
content.addAll(_messagesToSave);
debugPrint("updating student analytics");
_clearMessages();
await classRoom.client.setRoomStateWithKey(
classRoom.id,
_event.type,
_event.stateKey!,
content.toJson(),
);
}
_addMessage(RecentMessageRecord message) {
if (_messagesToSave.every((e) => e.eventId != message.eventId)) {
_messagesToSave.add(message);
} else {
debugger(when: kDebugMode);
ErrorHandler.logError(
m: "adding message twice in StudentAnalyticsEvent._addMessage",
);
}
//PTODO - save to local storagge
}
_clearMessages() {
_messagesToSave.clear();
//PTODO - clear local storagge
}
Future<TimeSeriesTotals> getTotals(String? chatId) async {
final TimeSeriesTotals totals = TimeSeriesTotals.empty;
final msgs = chatId == null
? content.messages
: content.messages.where((msg) => msg.chatId == chatId);
for (final msg in msgs) {
totals.increment(msg);
}
return totals;
}
Future<TimeSeriesInterval> getTimeServiesInterval(
DateTime start,
DateTime end,
String? chatId,
) async {
final TimeSeriesInterval interval = TimeSeriesInterval(
start: start,
end: end,
totals: TimeSeriesTotals.empty,
);
for (final msg in content.messages) {
if (msg.time.isAfter(start) &&
msg.time.isBefore(end) &&
(chatId == null || chatId == msg.chatId)) {
interval.totals.increment(msg);
}
}
return interval;
}
}
// import 'dart:developer';
// import 'package:fluffychat/pangea/extensions/client_extension.dart';
// import 'package:fluffychat/pangea/models/student_analytics_summary_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';
// import 'chart_analytics_model.dart';
// class StudentAnalyticsEvent {
// late Event _event;
// StudentAnalyticsSummary? _contentCache;
// List<RecentMessageRecord> _messagesToSave = [];
// StudentAnalyticsEvent({required Event event}) {
// if (event.type != PangeaEventTypes.studentAnalyticsSummary) {
// throw Exception(
// "${event.type} should not be used to make a StudentAnalyticsEvent",
// );
// }
// _event = event;
// _messagesToSave = [];
// }
// Event get event => _event;
// StudentAnalyticsSummary get content {
// _contentCache ??= StudentAnalyticsSummary.fromJson(event.content);
// return _contentCache!;
// }
// Future<void> removeEdittedMessages(
// RecentMessageRecord message,
// ) async {
// final List<String> removeIds = await event.room.client.getEditHistory(
// message.chatId,
// message.eventId,
// );
// if (removeIds.isEmpty) return;
// _messagesToSave.removeWhere(
// (msg) => removeIds.any((e) => e == msg.eventId),
// );
// content.removeEdittedMessages(
// event.room.client,
// removeIds,
// );
// }
// // Future<void> handleNewMessage(
// // RecentMessageRecord message, {
// // isEdit = false,
// // }) async {
// // if (isEdit) {
// // await removeEdittedMessages(message);
// // }
// // // _addMessage(message);
// // _messagesToSave.add(message);
// // debugPrint("messages to save is now: ${_messagesToSave.length}");
// // if (DateTime.now().difference(content.lastUpdated).inMinutes >
// // ClassDefaultValues.minutesDelayToUpdateMyAnalytics) {
// // _updateStudentAnalytics();
// // }
// // }
// // Future<void> bulkUpdate(List<RecentMessageRecord> messages) async {
// // // if (event.room.client.userID != _event.stateKey) {
// // // debugger(when: kDebugMode);
// // // ErrorHandler.logError(
// // // m: "should not be in bulkUpdate ${event.room.client.userID} != ${_event.stateKey}",
// // // );
// // // return;
// // // }
// // for (final message in messages) {
// // await removeEdittedMessages(message);
// // }
// // _messagesToSave.addAll(messages);
// // await _updateStudentAnalytics();
// // }
// // Future<void> _updateStudentAnalytics() async {
// // content.lastUpdated = DateTime.now();
// // content.addAll(_messagesToSave);
// // _clearMessages();
// // await event.room.client.setRoomStateWithKey(
// // event.room.id,
// // _event.type,
// // '',
// // content.toJson(),
// // );
// // }
// Future<void> updateStudentAnalytics() async {
// content.lastUpdated = DateTime.now();
// // content.addAll(_messagesToSave);
// // _clearMessages();
// await event.room.client.setRoomStateWithKey(
// event.room.id,
// _event.type,
// '',
// content.toJson(),
// );
// await event.room.postLoad();
// }
// _addMessage(RecentMessageRecord message) {
// if (_messagesToSave.every((e) => e.eventId != message.eventId)) {
// _messagesToSave.add(message);
// } else {
// debugger(when: kDebugMode);
// ErrorHandler.logError(
// m: "adding message twice in StudentAnalyticsEvent._addMessage",
// );
// }
// //PTODO - save to local storagge
// }
// _clearMessages() {
// _messagesToSave.clear();
// //PTODO - clear local storagge
// }
// Future<TimeSeriesTotals> getTotals(String? chatId) async {
// final TimeSeriesTotals totals = TimeSeriesTotals.empty;
// final msgs = chatId == null
// ? content.messages
// : content.messages.where((msg) => msg.chatId == chatId);
// for (final msg in msgs) {
// totals.increment(msg);
// }
// return totals;
// }
// Future<TimeSeriesInterval> getTimeServiesInterval(
// DateTime start,
// DateTime end,
// String? chatId,
// ) async {
// final TimeSeriesInterval interval = TimeSeriesInterval(
// start: start,
// end: end,
// totals: TimeSeriesTotals.empty,
// );
// for (final msg in content.messages) {
// if (msg.time.isAfter(start) &&
// msg.time.isBefore(end) &&
// (chatId == null || chatId == msg.chatId)) {
// interval.totals.increment(msg);
// }
// }
// return interval;
// }
// bool isAlreadyAdded(RecentMessageRecord message) {
// return content.messages.any(
// (element) => element.eventId == message.eventId,
// );
// }
// }

@ -58,7 +58,7 @@ class RecentMessageRecord {
class StudentAnalyticsSummary {
late List<RecentMessageRecord> _messages;
DateTime lastUpdated;
DateTime? lastUpdated;
StudentAnalyticsSummary({
required List<RecentMessageRecord> messages,
@ -69,11 +69,7 @@ class StudentAnalyticsSummary {
void addAll(List<RecentMessageRecord> msgs) {
for (final msg in msgs) {
if (_messages.any((element) => element.eventId == msg.eventId)) {
ErrorHandler.logError(
m: "adding message twice in StudentAnalyticsSummary.add",
);
} else {
if (!(_messages.any((element) => element.eventId == msg.eventId))) {
_messages.add(msg);
}
}
@ -92,7 +88,7 @@ class StudentAnalyticsSummary {
Map<String, dynamic> toJson() => {
_messagesKey: jsonEncode(_messages.map((e) => e.toJson()).toList()),
_lastUpdatedKey: lastUpdated.toIso8601String(),
_lastUpdatedKey: lastUpdated?.toIso8601String(),
};
factory StudentAnalyticsSummary.fromJson(json) {
@ -110,7 +106,9 @@ class StudentAnalyticsSummary {
}
return StudentAnalyticsSummary(
messages: savedMessages,
lastUpdated: DateTime.parse(json[_lastUpdatedKey]),
lastUpdated: json[_lastUpdatedKey] != null
? DateTime.parse(json[_lastUpdatedKey])
: null,
);
}
}

@ -0,0 +1,21 @@
import 'package:fluffychat/pangea/models/analytics_event.dart';
import 'package:fluffychat/pangea/models/summary_analytics_model.dart';
import 'package:matrix/matrix.dart';
import '../constants/pangea_event_types.dart';
class SummaryAnalyticsEvent extends AnalyticsEvent {
SummaryAnalyticsEvent({required Event event}) : super(event: event) {
if (event.type != PangeaEventTypes.summaryAnalytics) {
throw Exception(
"${event.type} should not be used to make a SummaryAnalyticsEvent",
);
}
}
@override
SummaryAnalyticsModel get content {
contentCache ??= SummaryAnalyticsModel.fromJson(event.content);
return contentCache as SummaryAnalyticsModel;
}
}

@ -0,0 +1,57 @@
import 'dart:convert';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:fluffychat/pangea/models/analytics_model.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
class SummaryAnalyticsModel extends AnalyticsModel {
late List<RecentMessageRecord> _messages;
SummaryAnalyticsModel({
required List<RecentMessageRecord> messages,
super.lastUpdated,
super.prevEventId,
super.prevLastUpdated,
}) {
_messages = messages;
}
List<RecentMessageRecord> get messages => _messages;
static const _messagesKey = "msgs";
static const _lastUpdatedKey = "lupt";
Map<String, dynamic> toJson() => {
_messagesKey: jsonEncode(_messages.map((e) => e.toJson()).toList()),
_lastUpdatedKey: lastUpdated?.toIso8601String(),
ModelKey.prevEventId: prevEventId,
ModelKey.prevLastUpdated: prevLastUpdated?.toIso8601String(),
};
factory SummaryAnalyticsModel.fromJson(json) {
List<RecentMessageRecord> savedMessages = [];
try {
savedMessages = json[_messagesKey] != null
? (jsonDecode(json[_messagesKey] ?? "[]") as Iterable)
.map((e) => RecentMessageRecord.fromJson(e))
.toList()
.cast<RecentMessageRecord>()
: [];
} catch (err, stack) {
if (kDebugMode) rethrow;
ErrorHandler.logError(e: err, s: stack);
}
return SummaryAnalyticsModel(
messages: savedMessages,
lastUpdated: json[_lastUpdatedKey] != null
? DateTime.parse(json[_lastUpdatedKey])
: null,
prevEventId: json[ModelKey.prevEventId],
prevLastUpdated: json[ModelKey.prevLastUpdated] != null
? DateTime.parse(json[ModelKey.prevLastUpdated])
: null,
);
}
}

@ -1,3 +1,6 @@
import 'dart:async';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
@ -14,115 +17,135 @@ import 'list_summary_analytics.dart';
class AnalyticsListTile extends StatefulWidget {
const AnalyticsListTile({
super.key,
required this.model,
required this.displayName,
required this.defaultSelected,
required this.selected,
required this.avatar,
required this.type,
required this.id,
required this.allowNavigateOnSelect,
required this.selected,
required this.isSelected,
required this.onTap,
this.enabled = true,
required this.pangeaController,
// this.isEnabled = true,
this.showSpaceAnalytics = true,
this.refreshStream,
});
final void Function(AnalyticsSelected) onTap;
final AnalyticsSelected defaultSelected;
final AnalyticsSelected selected;
final Uri? avatar;
final String displayName;
final AnalyticsEntryType type;
final String id;
final ChartAnalyticsModel? model;
final bool allowNavigateOnSelect;
final void Function(AnalyticsSelected) onTap;
final bool selected;
final bool enabled;
final bool isSelected;
// final bool isEnabled;
final bool showSpaceAnalytics;
final PangeaController pangeaController;
final StreamController? refreshStream;
@override
AnalyticsListTileState createState() => AnalyticsListTileState();
}
class AnalyticsListTileState extends State<AnalyticsListTile> {
ChartAnalyticsModel? tileData;
StreamSubscription? refreshSubscription;
@override
void initState() {
super.initState();
setTileData();
refreshSubscription = widget.refreshStream?.stream.listen((forceUpdate) {
setTileData(forceUpdate: forceUpdate);
});
}
@override
void dispose() {
refreshSubscription?.cancel();
super.dispose();
}
Future<void> setTileData({forceUpdate = false}) async {
tileData = await MatrixState.pangeaController.analytics.getAnalytics(
defaultSelected: widget.defaultSelected,
selected: widget.selected,
forceUpdate: forceUpdate,
);
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
final Room? room = Matrix.of(context).client.getRoomById(widget.id);
final Room? room =
Matrix.of(context).client.getRoomById(widget.selected.id);
return Material(
color: widget.selected
color: widget.isSelected
? Theme.of(context).colorScheme.secondaryContainer
: Colors.transparent,
child: Opacity(
opacity: widget.enabled ? 1 : 0.5,
child: Tooltip(
message: widget.enabled
? ""
: widget.type == AnalyticsEntryType.room
? L10n.of(context)!.joinToView
: L10n.of(context)!.studentAnalyticsNotAvailable,
child: ListTile(
leading: widget.type == AnalyticsEntryType.privateChats
? CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
radius: Avatar.defaultSize / 2,
child: const Icon(Icons.forum),
)
: Avatar(
mxContent: widget.avatar,
name: widget.displayName,
littleIcon: room?.roomTypeIcon,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
widget.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.bodyLarge!.color,
),
child: Tooltip(
message: widget.selected.type == AnalyticsEntryType.room
? L10n.of(context)!.joinToView
: L10n.of(context)!.studentAnalyticsNotAvailable,
child: ListTile(
leading: widget.selected.type == AnalyticsEntryType.privateChats
? CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
radius: Avatar.defaultSize / 2,
child: const Icon(Icons.forum),
)
: Avatar(
mxContent: widget.avatar,
name: widget.selected.displayName,
littleIcon: room?.roomTypeIcon,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
widget.selected.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.bodyLarge!.color,
),
),
Tooltip(
message: L10n.of(context)!.timeOfLastMessage,
child: Text(
widget.model?.lastMessage?.localizedTimeShort(context) ??
"",
style: TextStyle(
fontSize: 13,
color: Theme.of(context).textTheme.bodyMedium!.color,
),
),
Tooltip(
message: L10n.of(context)!.timeOfLastMessage,
child: Text(
tileData?.lastMessage?.localizedTimeShort(context) ?? "",
style: TextStyle(
fontSize: 13,
color: Theme.of(context).textTheme.bodyMedium!.color,
),
),
],
),
subtitle: widget.showSpaceAnalytics || !(room?.isSpace ?? false)
? ListSummaryAnalytics(
chartAnalytics: widget.model,
)
: null,
selected: widget.selected,
onTap: () {
(room?.isSpace ?? false) && widget.allowNavigateOnSelect
? context.go(
'/rooms/analytics/${room!.id}',
)
: widget.onTap(
AnalyticsSelected(
widget.id,
widget.type,
widget.displayName,
),
);
},
trailing: (room?.isSpace ?? false) &&
widget.type != AnalyticsEntryType.privateChats &&
widget.allowNavigateOnSelect
? const Icon(Icons.chevron_right)
: null,
),
],
),
subtitle: widget.showSpaceAnalytics || !(room?.isSpace ?? false)
? ListSummaryAnalytics(
chartAnalytics: tileData,
)
: null,
selected: widget.isSelected,
onTap: () {
(room?.isSpace ?? false) && widget.allowNavigateOnSelect
? context.go(
'/rooms/analytics/${room!.id}',
)
: widget.onTap(widget.selected);
},
trailing: (room?.isSpace ?? false) &&
widget.selected.type != AnalyticsEntryType.privateChats &&
widget.allowNavigateOnSelect
? const Icon(Icons.chevron_right)
: null,
),
),
);

@ -1,12 +1,10 @@
import 'dart:async';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/extensions/client_extension.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics_view.dart';
import 'package:fluffychat/pangea/pages/analytics/student_analytics/student_analytics.dart';
import 'package:flutter/material.dart';
import 'package:future_loading_dialog/future_loading_dialog.dart';
import 'package:matrix/matrix.dart';
import '../../../widgets/matrix.dart';
import '../../controllers/pangea_controller.dart';
@ -17,7 +15,6 @@ import '../../models/chart_analytics_model.dart';
class BaseAnalyticsPage extends StatefulWidget {
final String pageTitle;
final List<TabData> tabs;
final Future Function(BuildContext) refreshData;
final AnalyticsSelected defaultSelected;
final AnalyticsSelected? alwaysSelected;
@ -27,7 +24,6 @@ class BaseAnalyticsPage extends StatefulWidget {
super.key,
required this.pageTitle,
required this.tabs,
required this.refreshData,
required this.alwaysSelected,
required this.defaultSelected,
this.myAnalyticsController,
@ -42,53 +38,57 @@ class BaseAnalyticsController extends State<BaseAnalyticsPage> {
BarChartViewSelection? selectedView;
AnalyticsSelected? selected;
String? currentLemma;
ChartAnalyticsModel? chartData;
StreamController refreshStream = StreamController.broadcast();
bool isSelected(String chatOrStudentId) => chatOrStudentId == selected?.id;
ChartAnalyticsModel? chartData(
BuildContext context,
AnalyticsSelected? selectedParam,
) {
final AnalyticsSelected analyticsSelected =
selectedParam ?? widget.defaultSelected;
if (analyticsSelected.type == AnalyticsEntryType.privateChats) {
return pangeaController.analytics.getAnalyticsLocal(
classId: analyticsSelected.id,
chatId: AnalyticsEntryType.privateChats.toString(),
);
}
@override
void initState() {
super.initState();
setChartData();
}
Future<void> onRefresh() async {
await showFutureLoadingDialog(
context: context,
future: () async {
debugPrint("updating analytics");
await pangeaController.myAnalytics.updateAnalytics();
await setChartData(forceUpdate: true);
refreshStream.add(true);
},
);
}
String? chatId = analyticsSelected.type == AnalyticsEntryType.room
? analyticsSelected.id
: null;
chatId ??= widget.alwaysSelected?.type == AnalyticsEntryType.room
? widget.alwaysSelected?.id
: null;
String? studentId = analyticsSelected.type == AnalyticsEntryType.student
? analyticsSelected.id
: null;
studentId ??= widget.alwaysSelected?.type == AnalyticsEntryType.student
? widget.alwaysSelected?.id
: null;
String? classId = analyticsSelected.type == AnalyticsEntryType.space
? analyticsSelected.id
: null;
classId ??= widget.alwaysSelected?.type == AnalyticsEntryType.space
? widget.alwaysSelected?.id
: null;
final data = pangeaController.analytics.getAnalyticsLocal(
classId: classId,
chatId: chatId,
studentId: studentId,
Future<ChartAnalyticsModel> fetchChartData(
AnalyticsSelected? params, {
forceUpdate = false,
}) async {
ChartAnalyticsModel? data = pangeaController.analytics.getAnalyticsLocal(
timeSpan: currentTimeSpan,
defaultSelected: widget.defaultSelected,
selected: params,
forceUpdate: forceUpdate,
);
data ??= await pangeaController.analytics.getAnalytics(
defaultSelected: widget.defaultSelected,
selected: params,
forceUpdate: forceUpdate,
);
return data;
}
Future<void> setChartData({forceUpdate = false}) async {
final ChartAnalyticsModel newData = await fetchChartData(
selected,
forceUpdate: forceUpdate,
);
setState(() => chartData = newData);
}
TimeSpan get currentTimeSpan =>
pangeaController.analytics.currentAnalyticsTimeSpan;
@ -103,33 +103,13 @@ class BaseAnalyticsController extends State<BaseAnalyticsPage> {
}
Future<void> toggleSelection(AnalyticsSelected selectedParam) async {
final bool joinSelectedRoom =
selectedParam.type == AnalyticsEntryType.room &&
!enableSelection(
selectedParam,
);
if (joinSelectedRoom) {
await showFutureLoadingDialog(
context: context,
future: () async {
final waitForRoom = Matrix.of(context).client.waitForRoomInSync(
selectedParam.id,
join: true,
);
await Matrix.of(context).client.joinRoom(selectedParam.id);
await waitForRoom;
},
);
}
setState(() {
debugPrint("selectedParam.id is ${selectedParam.id}");
currentLemma = null;
selected = isSelected(selectedParam.id) ? null : selectedParam;
});
pangeaController.analytics.setConstructs(
await pangeaController.analytics.setConstructs(
constructType: ConstructType.grammar,
defaultSelected: widget.defaultSelected,
selected: selected,
@ -141,54 +121,28 @@ class BaseAnalyticsController extends State<BaseAnalyticsPage> {
Future<void> toggleTimeSpan(BuildContext context, TimeSpan timeSpan) async {
await pangeaController.analytics.setCurrentAnalyticsTimeSpan(timeSpan);
await widget.refreshData(context);
await pangeaController.analytics.setConstructs(
constructType: ConstructType.grammar,
defaultSelected: widget.defaultSelected,
selected: selected,
removeIT: true,
);
await setChartData();
setState(() {});
refreshStream.add(false);
}
void setSelectedView(BarChartViewSelection? view) {
currentLemma = null;
selectedView = view;
if (!enableSelection(selected)) {
toggleSelection(selected!);
}
setState(() {});
refreshStream.add(false);
}
void setCurrentLemma(String? lemma) {
currentLemma = lemma;
setState(() {});
}
bool enableSelection(AnalyticsSelected? selectedParam) {
if (selectedView == BarChartViewSelection.grammar) {
if (selectedParam?.type == AnalyticsEntryType.room) {
return Matrix.of(context)
.client
.getRoomById(selectedParam!.id)
?.membership ==
Membership.join;
}
if (selectedParam?.type == AnalyticsEntryType.student) {
final String? langCode =
pangeaController.languageController.activeL2Code(
roomID: widget.defaultSelected.id,
);
if (langCode == null) return false;
return Matrix.of(context).client.analyticsRoomLocal(
langCode,
selectedParam?.id,
) !=
null;
}
}
return true;
refreshStream.add(false);
}
@override

@ -29,10 +29,7 @@ class BaseAnalyticsView extends StatelessWidget {
switch (controller.selectedView!) {
case BarChartViewSelection.messages:
return MessagesBarChart(
chartAnalytics: controller.chartData(
context,
controller.selected,
),
chartAnalytics: controller.chartData,
);
case BarChartViewSelection.grammar:
return ConstructList(
@ -41,6 +38,7 @@ class BaseAnalyticsView extends StatelessWidget {
selected: controller.selected,
controller: controller,
pangeaController: controller.pangeaController,
refreshStream: controller.refreshStream,
);
}
}
@ -98,19 +96,34 @@ class BaseAnalyticsView extends StatelessWidget {
icon: const Icon(Icons.arrow_back),
onPressed: controller.navigate,
),
actions: [
TimeSpanMenuButton(
value: controller.currentTimeSpan,
onChange: (TimeSpan value) =>
controller.toggleTimeSpan(context, value),
),
],
// actions: [
// TimeSpanMenuButton(
// value: controller.currentTimeSpan,
// onChange: (TimeSpan value) =>
// controller.toggleTimeSpan(context, value),
// ),
// ],
),
body: MaxWidthBody(
withScrolling: false,
child: controller.selectedView != null
? Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: controller.onRefresh,
tooltip: L10n.of(context)!.refresh,
),
TimeSpanMenuButton(
value: controller.currentTimeSpan,
onChange: (TimeSpan value) =>
controller.toggleTimeSpan(context, value),
),
],
),
Expanded(
flex: 1,
child: chartView(context),
@ -153,28 +166,18 @@ class BaseAnalyticsView extends StatelessWidget {
children: [
...controller.widget.tabs[0].items.map(
(item) => AnalyticsListTile(
refreshStream:
controller.refreshStream,
avatar: item.avatar,
model: controller.chartData(
context,
AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
"",
),
defaultSelected: controller
.widget.defaultSelected,
selected: AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
item.displayName,
),
displayName: item.displayName,
id: item.id,
type:
controller.widget.tabs[0].type,
selected:
isSelected:
controller.isSelected(item.id),
enabled: controller.enableSelection(
AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
"",
),
),
showSpaceAnalytics: false,
onTap: (_) =>
controller.toggleSelection(
@ -188,35 +191,33 @@ class BaseAnalyticsView extends StatelessWidget {
.widget
.tabs[0]
.allowNavigateOnSelect,
pangeaController:
controller.pangeaController,
),
),
if (controller
.widget.defaultSelected.type ==
AnalyticsEntryType.space)
AnalyticsListTile(
refreshStream:
controller.refreshStream,
defaultSelected: controller
.widget.defaultSelected,
avatar: null,
model: controller.chartData(
context,
AnalyticsSelected(
controller
.widget.defaultSelected.id,
AnalyticsEntryType.privateChats,
L10n.of(context)!
.allPrivateChats,
),
selected: AnalyticsSelected(
controller
.widget.defaultSelected.id,
AnalyticsEntryType.privateChats,
L10n.of(context)!.allPrivateChats,
),
displayName: L10n.of(context)!
.allPrivateChats,
id: controller
.widget.defaultSelected.id,
type:
AnalyticsEntryType.privateChats,
allowNavigateOnSelect: false,
selected: controller.isSelected(
isSelected: controller.isSelected(
controller
.widget.defaultSelected.id,
),
onTap: controller.toggleSelection,
pangeaController:
controller.pangeaController,
),
],
),
@ -226,36 +227,25 @@ class BaseAnalyticsView extends StatelessWidget {
children: controller.widget.tabs[1].items
.map(
(item) => AnalyticsListTile(
refreshStream:
controller.refreshStream,
avatar: item.avatar,
model: controller.chartData(
context,
AnalyticsSelected(
item.id,
controller
.widget.tabs[1].type,
"",
),
defaultSelected: controller
.widget.defaultSelected,
selected: AnalyticsSelected(
item.id,
controller.widget.tabs[1].type,
item.displayName,
),
displayName: item.displayName,
id: item.id,
type: controller
.widget.tabs[1].type,
selected: controller
isSelected: controller
.isSelected(item.id),
onTap: controller.toggleSelection,
allowNavigateOnSelect: controller
.widget
.tabs[1]
.allowNavigateOnSelect,
enabled:
controller.enableSelection(
AnalyticsSelected(
item.id,
controller
.widget.tabs[1].type,
"",
),
),
pangeaController:
controller.pangeaController,
),
)
.toList(),

@ -1,12 +1,8 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/chart_analytics_model.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:fluffychat/pangea/widgets/common/list_placeholder.dart';
import 'package:fluffychat/pangea/widgets/common/p_circular_loader.dart';
@ -16,14 +12,12 @@ import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import '../../../../widgets/matrix.dart';
import '../../../controllers/pangea_controller.dart';
import '../../../utils/sync_status_util_v2.dart';
import 'class_analytics_view.dart';
enum AnalyticsPageType { classList, student, classDetails }
class ClassAnalyticsPage extends StatefulWidget {
// final AnalyticsPageType type;
const ClassAnalyticsPage({super.key});
@override
@ -31,48 +25,25 @@ class ClassAnalyticsPage extends StatefulWidget {
}
class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
final PangeaController _pangeaController = MatrixState.pangeaController;
bool _initialized = false;
StreamSubscription<Event>? stateSub;
Timer? refreshTimer;
// StreamSubscription<Event>? stateSub;
// Timer? refreshTimer;
List<SpaceRoomsChunk> chats = [];
List<User> students = [];
String? get classId => GoRouterState.of(context).pathParameters['classid'];
Room? _classRoom;
Room? get classRoom {
if (_classRoom == null || _classRoom!.id != classId) {
debugPrint("updating _classRoom");
_classRoom = classId != null
? Matrix.of(context).client.getRoomById(classId!)
: null;
getChatAndStudents()
.then(
(_) => _pangeaController.analytics.setConstructs(
constructType: ConstructType.grammar,
defaultSelected: AnalyticsSelected(
classId!,
AnalyticsEntryType.space,
className(context),
),
removeIT: true,
forceUpdate: true,
),
)
.then(
(_) => getChatAndStudentAnalytics(context, true),
);
}
return _classRoom;
}
String className(BuildContext context) {
return classRoom?.name ?? "";
}
@override
void initState() {
super.initState();
@ -80,14 +51,6 @@ class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
if (classRoom == null || (!(classRoom?.isSpace ?? false))) {
context.go('/rooms');
}
stateSub = _pangeaController.matrixState.client.onRoomState.stream
.where(
(event) =>
event.type == PangeaEventTypes.studentAnalyticsSummary &&
event.roomId == classId,
)
.listen(onStateUpdate);
getChatAndStudents();
});
}
@ -122,21 +85,12 @@ class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
}
}
void onStateUpdate(Event newState) {
if (!(refreshTimer?.isActive ?? false)) {
refreshTimer = Timer(
const Duration(seconds: 3),
() => getChatAndStudentAnalytics(context, true),
);
}
}
@override
void dispose() {
super.dispose();
refreshTimer?.cancel();
stateSub?.cancel();
}
// @override
// void dispose() {
// super.dispose();
// refreshTimer?.cancel();
// stateSub?.cancel();
// }
@override
Widget build(BuildContext context) {
@ -146,57 +100,10 @@ class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
// but this is computationally expensive!
// key: UniqueKey(),
shimmerChild: const ListPlaceholder(),
onFinish: () {
getChatAndStudentAnalytics(context);
},
// onFinish: () {
// getChatAndStudentAnalytics(context);
// },
child: ClassAnalyticsView(this),
);
}
Future<void> getChatAndStudentAnalytics(
BuildContext context, [
forceUpdate = false,
]) async {
try {
if (classRoom == null) {
debugger(when: kDebugMode);
ErrorHandler.logError(m: 'classroom should not be null');
}
final List<Future<ChartAnalyticsModel?>> analyticsFutures = [];
for (final student in students) {
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
classRoom: classRoom,
studentId: student.id,
forceUpdate: forceUpdate,
),
);
}
for (final chat in chats) {
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
classRoom: classRoom,
chatId: chat.roomId,
forceUpdate: forceUpdate,
),
);
}
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
classRoom: classRoom,
forceUpdate: forceUpdate,
),
);
analyticsFutures.add(
_pangeaController.analytics.getAnalyticsForPrivateChats(
classRoom: classRoom,
forceUpdate: forceUpdate,
),
);
await Future.wait(analyticsFutures);
if (mounted) setState(() {});
} catch (err) {
debugger(when: kDebugMode);
}
}
}

@ -50,16 +50,15 @@ class ClassAnalyticsView extends StatelessWidget {
? BaseAnalyticsPage(
pageTitle: pageTitle,
tabs: [tab1, tab2],
refreshData: controller.getChatAndStudentAnalytics,
alwaysSelected: AnalyticsSelected(
controller.classId!,
AnalyticsEntryType.space,
controller.className(context),
controller.classRoom?.name ?? "",
),
defaultSelected: AnalyticsSelected(
controller.classId!,
AnalyticsEntryType.space,
controller.className(context),
controller.classRoom?.name ?? "",
),
)
: const SizedBox();

@ -1,12 +1,12 @@
import 'dart:async';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/pangea/pages/analytics/class_list/class_list_view.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import '../../../../widgets/matrix.dart';
import '../../../constants/pangea_event_types.dart';
import '../../../controllers/pangea_controller.dart';
import '../../../models/chart_analytics_model.dart';
import '../../../utils/sync_status_util_v2.dart';
@ -22,42 +22,6 @@ class AnalyticsClassList extends StatefulWidget {
class AnalyticsClassListController extends State<AnalyticsClassList> {
PangeaController pangeaController = MatrixState.pangeaController;
List<ChartAnalyticsModel> models = [];
StreamSubscription<Event>? stateSub;
Map<String, Timer> refreshTimer = {};
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () async {
stateSub = pangeaController.matrixState.client.onRoomState.stream
.where(
(event) => event.type == PangeaEventTypes.studentAnalyticsSummary,
)
.listen(onStateUpdate);
});
}
void onStateUpdate(Event newState) {
if (!(refreshTimer[newState.room.id]?.isActive ?? false)) {
refreshTimer[newState.room.id] = Timer(
const Duration(seconds: 3),
() {
if (newState.room.isSpace) {
updateClassAnalytics(context, newState.room);
}
},
);
}
}
@override
void dispose() {
super.dispose();
for (final timer in refreshTimer.values) {
timer.cancel();
}
stateSub?.cancel();
}
@override
Widget build(BuildContext context) {
@ -65,32 +29,32 @@ class AnalyticsClassListController extends State<AnalyticsClassList> {
shimmerChild: const ListPlaceholder(),
child: AnalyticsClassListView(this),
onFinish: () {
getAllClassAnalytics(context);
// getAllClassAnalytics(context);
},
);
}
Future<void> getAllClassAnalytics(BuildContext context) async {
await pangeaController.analytics.allClassAnalytics();
setState(() {
debugPrint("class list post getAllClassAnalytics");
});
}
Future<void> updateClassAnalytics(
BuildContext context,
Room classRoom,
Future<ChartAnalyticsModel?> updateClassAnalytics(
Room? space,
) async {
await pangeaController.analytics
.getAnalytics(classRoom: classRoom, forceUpdate: true);
setState(() {
debugPrint("class list post updateClassAnalytics");
});
if (space == null) {
return null;
}
final data = await pangeaController.analytics.getAnalytics(
defaultSelected: AnalyticsSelected(
space.id,
AnalyticsEntryType.space,
space.name,
),
forceUpdate: true,
);
setState(() {});
return data;
}
void toggleTimeSpan(BuildContext context, TimeSpan timeSpan) {
pangeaController.analytics.setCurrentAnalyticsTimeSpan(timeSpan);
setState(() {});
getAllClassAnalytics(context);
}
}

@ -50,18 +50,23 @@ class AnalyticsClassListView extends StatelessWidget {
builder: (context, snapshot) => ListView.builder(
itemCount: snapshot.hasData ? snapshot.data?.length ?? 0 : 0,
itemBuilder: (context, i) => AnalyticsListTile(
defaultSelected: AnalyticsSelected(
snapshot.data![i].id,
AnalyticsEntryType.space,
"",
),
avatar: snapshot.data![i].avatar,
model: controller.pangeaController.analytics
.getAnalyticsLocal(classId: snapshot.data![i].id),
displayName: snapshot.data![i].name,
id: snapshot.data![i].id,
type: AnalyticsEntryType.space,
// selected: false,
selected: AnalyticsSelected(
snapshot.data![i].id,
AnalyticsEntryType.space,
snapshot.data![i].name,
),
onTap: (selected) => context.go(
'/rooms/analytics/${selected.id}',
),
allowNavigateOnSelect: true,
selected: false,
isSelected: false,
pangeaController: controller.pangeaController,
),
),
),

@ -2,7 +2,6 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/controllers/my_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
@ -25,6 +24,7 @@ class ConstructList extends StatefulWidget {
final AnalyticsSelected? selected;
final BaseAnalyticsController controller;
final PangeaController pangeaController;
final StreamController refreshStream;
const ConstructList({
super.key,
@ -32,6 +32,7 @@ class ConstructList extends StatefulWidget {
required this.defaultSelected,
required this.controller,
required this.pangeaController,
required this.refreshStream,
this.selected,
});
@ -77,6 +78,7 @@ class ConstructListState extends State<ConstructList> {
pangeaController: widget.pangeaController,
defaultSelected: widget.defaultSelected,
selected: widget.selected,
refreshStream: widget.refreshStream,
),
],
);
@ -93,12 +95,12 @@ class ConstructListState extends State<ConstructList> {
// subtitle = total uses, equal to construct.content.uses.length
// list has a fixed height of 400 and is scrollable
class ConstructListView extends StatefulWidget {
// final List<ConstructEvent> constructs;
final bool init;
final BaseAnalyticsController controller;
final PangeaController pangeaController;
final AnalyticsSelected defaultSelected;
final AnalyticsSelected? selected;
final StreamController refreshStream;
const ConstructListView({
super.key,
@ -106,6 +108,7 @@ class ConstructListView extends StatefulWidget {
required this.controller,
required this.pangeaController,
required this.defaultSelected,
required this.refreshStream,
this.selected,
});
@ -118,55 +121,30 @@ class ConstructListViewState extends State<ConstructListView> {
final Map<String, PangeaMessageEvent> _msgEventCache = {};
final List<PangeaMessageEvent> _msgEvents = [];
bool fetchingUses = false;
StreamSubscription<Event>? stateSub;
Timer? refreshTimer;
StreamSubscription? refreshSubscription;
@override
void initState() {
super.initState();
stateSub = Matrix.of(context)
.client
.onRoomState
.stream
//could optimize here be determing if the vocab event is relevant for
//currently displayed data
.where((event) => event.type == PangeaEventTypes.vocab)
.listen(onStateUpdate);
}
Future<void> onStateUpdate(Event? newState) async {
debugPrint("onStateUpdate construct list");
if (refreshTimer?.isActive ?? false) return;
refreshTimer = Timer(
const Duration(seconds: 3),
() async {
await widget.pangeaController.analytics.setConstructs(
constructType: ConstructType.grammar,
removeIT: true,
defaultSelected: widget.defaultSelected,
selected: widget.selected,
forceUpdate: true,
);
await fetchUses();
},
);
refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) {
widget.pangeaController.analytics
.setConstructs(
constructType: ConstructType.grammar,
removeIT: true,
defaultSelected: widget.defaultSelected,
selected: widget.selected,
forceUpdate: true,
)
.then((_) => setState(() {}));
});
}
@override
void dispose() {
refreshSubscription?.cancel();
super.dispose();
refreshTimer?.cancel();
stateSub?.cancel();
}
// @override
// void didUpdateWidget(ConstructListView oldWidget) {
// super.didUpdateWidget(oldWidget);
// fetchUses();
// }
int get lemmaIndex =>
constructs?.indexWhere(
(element) => element.lemma == widget.controller.currentLemma,
@ -241,16 +219,19 @@ class ConstructListViewState extends State<ConstructListView> {
}
}
List<AggregateConstructUses>? get constructs =>
widget.pangeaController.analytics.constructs != null
? widget.pangeaController.myAnalytics
.aggregateConstructData(
widget.pangeaController.analytics.constructs!,
)
.sorted(
(a, b) => b.uses.length.compareTo(a.uses.length),
)
: null;
List<AggregateConstructUses>? get constructs {
if (widget.pangeaController.analytics.constructs == null) {
return null;
}
return widget.pangeaController.myAnalytics
.aggregateConstructData(widget.pangeaController.analytics.constructs!)
.where((lemmaUses) => lemmaUses.uses.isNotEmpty)
.sorted((a, b) {
final int cmp = b.uses.length.compareTo(a.uses.length);
if (cmp != 0) return cmp;
return a.lemma.compareTo(b.lemma);
}).toList();
}
AggregateConstructUses? get currentConstruct => constructs?.firstWhereOrNull(
(element) => element.lemma == widget.controller.currentLemma,
@ -302,6 +283,7 @@ class ConstructListViewState extends State<ConstructListView> {
child: Center(child: CircularProgressIndicator()),
);
}
if ((constructs?.isEmpty ?? true) ||
(widget.controller.currentLemma != null && currentConstruct == null)) {
return Expanded(

@ -1,9 +1,6 @@
import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/chart_analytics_model.dart';
import 'package:fluffychat/pangea/widgets/common/list_placeholder.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
@ -11,7 +8,6 @@ import 'package:matrix/matrix.dart';
import '../../../../widgets/matrix.dart';
import '../../../controllers/pangea_controller.dart';
import '../../../extensions/client_extension.dart';
import '../../../utils/sync_status_util_v2.dart';
import '../base_analytics.dart';
import 'student_analytics_view.dart';
@ -26,128 +22,38 @@ class StudentAnalyticsPage extends StatefulWidget {
class StudentAnalyticsController extends State<StudentAnalyticsPage> {
final PangeaController _pangeaController = MatrixState.pangeaController;
AnalyticsSelected? selected;
StreamSubscription<Event>? stateSub;
Timer? refreshTimer;
StreamSubscription? stateSub;
List<Room> _chats = [];
List<Room> _spaces = [];
void onStateUpdate(Event newState) {
if (!(refreshTimer?.isActive ?? false)) {
refreshTimer = Timer(
const Duration(seconds: 3),
() => getClassAndChatAnalytics(context, true),
);
}
@override
void initState() {
super.initState();
stateSub = _pangeaController.myAnalytics.stateStream.listen((_) {
setState(() {});
});
}
@override
void dispose() {
super.dispose();
refreshTimer?.cancel();
stateSub?.cancel();
super.dispose();
}
Future<void> initialize() async {
await getClassAndChatAnalytics(context);
stateSub = _pangeaController.matrixState.client.onRoomState.stream
.where(
(event) =>
event.type == PangeaEventTypes.studentAnalyticsSummary &&
event.senderId == userId,
)
.listen(onStateUpdate);
}
@override
Widget build(BuildContext context) {
return PLoadingStatusV2(
// if we everr want it rebuild the whole thing each time (and run initState again)
// but this is computationally expensive!
// key: UniqueKey(),
shimmerChild: const ListPlaceholder(),
onFinish: initialize,
child: StudentAnalyticsView(this),
);
}
Future<void> getClassAndChatAnalytics(
BuildContext context, [
forceUpdate = false,
]) async {
final List<Future<ChartAnalyticsModel?>> analyticsFutures = [];
for (final chat in (await getChats())) {
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
chatId: chat.id,
studentId: userId,
forceUpdate: forceUpdate,
),
);
}
for (final space in (await getSpaces())) {
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
classRoom: space,
studentId: userId,
forceUpdate: forceUpdate,
),
);
}
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
studentId: userId,
forceUpdate: forceUpdate,
),
);
await Future.wait(analyticsFutures);
setState(() {});
}
Future<List<Room>> getSpaces() async {
final List<Room> rooms = await _pangeaController
.matrixState.client.classesAndExchangesImStudyingIn;
setState(() => _spaces = rooms);
return rooms;
}
List<Room>? get spaces {
try {
if (_spaces.isNotEmpty) return _spaces;
getSpaces();
return _spaces;
} catch (err) {
debugger(when: kDebugMode);
return [];
List<Room> get chats {
if (_pangeaController.myAnalytics.studentChats.isEmpty) {
_pangeaController.myAnalytics
.setStudentChats()
.then((_) => setState(() {}));
}
return _pangeaController.myAnalytics.studentChats;
}
Future<List<Room>> getChats() async {
final List<String> teacherRoomIds =
await Matrix.of(context).client.teacherRoomIds;
_chats = Matrix.of(context)
.client
.rooms
.where(
(r) =>
!r.isSpace &&
!r.isAnalyticsRoom &&
!teacherRoomIds.contains(r.id),
)
.toList();
setState(() => _chats = _chats);
return _chats;
}
List<Room>? get chats {
try {
if (_chats.isNotEmpty) return _chats;
getChats();
return _chats;
} catch (err) {
debugger(when: kDebugMode);
return [];
List<Room> get spaces {
if (_pangeaController.myAnalytics.studentSpaces.isEmpty) {
_pangeaController.myAnalytics
.setStudentSpaces()
.then((_) => setState(() {}));
}
return _pangeaController.myAnalytics.studentSpaces;
}
String? get userId {
@ -156,6 +62,15 @@ class StudentAnalyticsController extends State<StudentAnalyticsPage> {
return id;
}
String get username =>
_pangeaController.matrixState.client.userID?.localpart ?? "";
@override
Widget build(BuildContext context) {
return PLoadingStatusV2(
// if we everr want it rebuild the whole thing each time (and run initState again)
// but this is computationally expensive!
// key: UniqueKey(),
shimmerChild: const ListPlaceholder(),
// onFinish: initialize,
child: StudentAnalyticsView(this),
);
}
}

@ -15,7 +15,7 @@ class StudentAnalyticsView extends StatelessWidget {
final TabData chatTabData = TabData(
type: AnalyticsEntryType.room,
icon: Icons.chat_bubble_outline,
items: (controller.chats ?? [])
items: (controller.chats)
.map(
(c) => TabItem(
avatar: c.avatar,
@ -47,7 +47,6 @@ class StudentAnalyticsView extends StatelessWidget {
? BaseAnalyticsPage(
pageTitle: pageTitle,
tabs: [chatTabData, classTabData],
refreshData: controller.getClassAndChatAnalytics,
alwaysSelected: AnalyticsSelected(
controller.userId!,
AnalyticsEntryType.student,

@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../enum/time_span.dart';
@ -16,6 +15,7 @@ class TimeSpanMenuButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<TimeSpan>(
offset: const Offset(0, 100),
icon: const Icon(Icons.calendar_month_outlined),
tooltip: L10n.of(context)!.changeDateRange,
initialValue: value,

@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/constants/pangea_event_types.dart';
import '../../widgets/matrix.dart';
import '../constants/class_default_values.dart';
import '../extensions/pangea_room_extension.dart';
@ -16,7 +14,6 @@ class ClassChatPowerLevels {
final Map<String, dynamic> powerLevelOverride = {};
powerLevelOverride['events'] = {
EventTypes.spaceChild: 0,
PangeaEventTypes.studentAnalyticsSummary: 0,
};
powerLevelOverride['users'] = {};

@ -113,7 +113,6 @@ abstract class ClientManager {
// #Pangea
PangeaEventTypes.classSettings,
PangeaEventTypes.rules,
PangeaEventTypes.vocab,
PangeaEventTypes.botOptions,
EventTypes.RoomTopic,
EventTypes.RoomAvatar,

Loading…
Cancel
Save