Merge pull request #128 from pangeachat/error-analytics

Error Analytics
This commit is contained in:
wcjord 2024-03-25 10:16:53 -04:00 committed by GitHub
commit 64f57e0e48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1849 additions and 1041 deletions

View file

@ -3949,5 +3949,6 @@
}
},
"kickBotWarning": "Kicking Pangea Bot will remove the conversation bot from this chat.",
"joinToView": "Join this room to view details",
"refresh": "Refresh"
}

View file

@ -105,9 +105,6 @@ class AudioPlayerState extends State<AudioPlayerWidget> {
});
_playAction();
} catch (e, s) {
// #Pangea
debugger();
// Pangea#
Logs().v('Could not download audio file', e, s);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(

View file

@ -1,6 +1,7 @@
import 'dart:developer';
import 'package:adaptive_dialog/adaptive_dialog.dart';
import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/extensions/client_extension.dart';
import 'package:fluffychat/pangea/utils/class_code.dart';
import 'package:fluffychat/pangea/utils/find_conversation_partner_dialog.dart';
@ -57,7 +58,11 @@ class ClientChooserButton extends StatelessWidget {
),
),
PopupMenuItem(
enabled: matrix.client.classesAndExchangesImTeaching.isNotEmpty,
enabled: matrix.client.rooms.any(
(room) =>
room.isSpace &&
room.ownPowerLevel >= ClassDefaultValues.powerLevelOfAdmin,
),
value: SettingsAction.classAnalytics,
child: Row(
children: [
@ -68,7 +73,7 @@ class ClientChooserButton extends StatelessWidget {
),
),
PopupMenuItem(
enabled: matrix.client.classesImIn.isNotEmpty,
enabled: matrix.client.classesAndExchangesImIn.isNotEmpty,
value: SettingsAction.myAnalytics,
child: Row(
children: [

View file

@ -1,5 +1,6 @@
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/extensions/client_extension.dart';
import 'package:fluffychat/pangea/models/class_model.dart';
import 'package:fluffychat/pangea/pages/class_settings/p_class_widgets/room_rules_editor.dart';
import 'package:fluffychat/pangea/widgets/class/add_class_and_invite.dart';
import 'package:fluffychat/pangea/widgets/class/add_space_toggles.dart';
@ -142,11 +143,25 @@ class NewSpaceView extends StatelessWidget {
? AddToClassMode.exchange
: AddToClassMode.chat,
),
RoomRulesEditor(
key: controller.rulesEditorKey,
roomId: null,
startOpen: false,
initialRules: Matrix.of(context).client.lastUpdatedRoomRules,
FutureBuilder<PangeaRoomRules?>(
future: Matrix.of(context).client.lastUpdatedRoomRules,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.done) {
return RoomRulesEditor(
key: controller.rulesEditorKey,
roomId: null,
startOpen: false,
initialRules: snapshot.data,
);
} else {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(
child: CircularProgressIndicator.adaptive(strokeWidth: 2),
),
);
}
},
),
// SwitchListTile.adaptive(
// title: Text(L10n.of(context)!.spaceIsPublic),

View file

@ -35,8 +35,8 @@ class ClassController extends BaseController {
Future<void> fixClassPowerLevels() async {
try {
final List<Future<void>> classFixes = [];
for (final room in _pangeaController
.matrixState.client.classesAndExchangesImTeaching) {
for (final room in (await _pangeaController
.matrixState.client.classesAndExchangesImTeaching)) {
classFixes.add(room.setClassPowerlLevels());
}
await Future.wait(classFixes);

View file

@ -1,10 +1,11 @@
import 'dart:developer';
import 'package:collection/collection.dart';
import 'package:fluffychat/pangea/constants/match_rule_ids.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/models/headwords.dart';
import 'package:fluffychat/pangea/models/student_analytics_summary_model.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics_page.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/pangea/utils/error_handler.dart';
import 'package:flutter/foundation.dart';
import 'package:matrix/matrix.dart';
@ -22,6 +23,7 @@ class AnalyticsController extends BaseController {
late PangeaController _pangeaController;
final List<CacheModel> _cachedModels = [];
final List<ConstructCacheEntry> _cachedConstructs = [];
AnalyticsController(PangeaController pangeaController) : super() {
_pangeaController = pangeaController;
@ -50,10 +52,10 @@ class AnalyticsController extends BaseController {
.save(_analyticsTimeSpanKey, timeSpan.toString());
}
Future<List<ChartAnalyticsModel?>> allClassAnalytics() {
Future<List<ChartAnalyticsModel?>> allClassAnalytics() async {
final List<Future<ChartAnalyticsModel?>> classAnalyticFutures = [];
for (final classRoom
in _pangeaController.matrixState.client.classesAndExchangesImTeaching) {
for (final classRoom in (await _pangeaController
.matrixState.client.classesAndExchangesImTeaching)) {
classAnalyticFutures.add(
getAnalytics(classRoom: classRoom),
);
@ -146,6 +148,7 @@ class AnalyticsController extends BaseController {
debugPrint("studentAnalyticsSummaryEvent is null");
}
}
final newModel = ChartAnalyticsModel(
timeSpan: timeSpan,
msgs: msgs,
@ -170,24 +173,6 @@ class AnalyticsController extends BaseController {
}
}
Future<VocabHeadwords> vocabHeadwordsWithTotals(
String langCode,
List<ConstructEvent> vocab, [
String? chatId,
]) async {
final VocabHeadwords vocabHeadwords =
await VocabHeadwords.getHeadwords(langCode);
for (final vocabList in vocabHeadwords.lists) {
for (final vocabEvent in vocab) {
vocabList.addVocabUse(
vocabEvent.content.lemma,
vocabEvent.content.uses,
);
}
}
return vocabHeadwords;
}
Future<ChartAnalyticsModel> getAnalyticsForPrivateChats({
TimeSpan? timeSpan,
required Room? classRoom,
@ -248,185 +233,400 @@ class AnalyticsController extends BaseController {
}
}
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;
}
Future<Room> myAnalyticsRoom(String langCode) =>
_pangeaController.matrixState.client.getMyAnalyticsRoom(langCode);
Future<List<ConstructEvent>> myConstructs(String langCode) async {
final Room analyticsRoom = await myAnalyticsRoom(langCode);
return analyticsRoom.allConstructEvents;
}
Future<List<ConstructEvent>> studentConstructs(
String studentId,
String langCode,
) {
final Room? analyticsRoom = _pangeaController.matrixState.client
.analyticsRoomLocal(langCode, studentId);
if (analyticsRoom == null) {
ErrorHandler.logError(
m: "analyticsRoom missing in studentConstructs",
s: StackTrace.current,
data: {
"studentId": studentId,
"langCode": langCode,
},
Room? studentAnalyticsRoom(String studentId, String langCode) =>
_pangeaController.matrixState.client.analyticsRoomLocal(
langCode,
studentId,
);
Future<List<ConstructEvent>> allMyConstructs(
String langCode, {
ConstructType? type,
}) async {
final Room analyticsRoom = await myAnalyticsRoom(langCode);
final List<String> adminSpaceRooms =
await _pangeaController.matrixState.client.teacherRoomIds;
final allConstructs = type == null
? await analyticsRoom.allConstructEvents
: (await analyticsRoom.allConstructEvents)
.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));
}
return analyticsRoom?.allConstructEvents ?? Future.value([]);
return allConstructs
.where((construct) => construct.content.uses.isNotEmpty)
.toList();
}
Future<List<ConstructEvent>> spaceMemberVocab(String spaceId) async {
await _pangeaController.matrixState.client.roomsLoading;
final Room? space =
_pangeaController.matrixState.client.getRoomById(spaceId);
if (space == null) {
throw Exception("space missing in spaceVocab");
}
final String? langCode = space.firstLanguageSettings?.targetLanguage;
final List<Future<List<ConstructEvent>>> vocabEventFutures = [];
Future<List<ConstructEvent>> allSpaceMemberConstructs(
Room space,
String langCode, {
ConstructType? type,
}) async {
final List<Future<List<ConstructEvent>>> constructEventFutures = [];
await space.postLoad();
await space.requestParticipants();
for (final student in space.students) {
final Room? room = _pangeaController.matrixState.client
.analyticsRoomLocal(langCode, student.id);
if (room != null) vocabEventFutures.add(room.allConstructEvents);
if (room != null) constructEventFutures.add(room.allConstructEvents);
}
final List<List<ConstructEvent>> allVocabLists =
await Future.wait(vocabEventFutures);
final List<List<ConstructEvent>> constructLists =
await Future.wait(constructEventFutures);
final List<ConstructEvent> allVocab = [];
for (final vocabList in allVocabLists) {
allVocab.addAll(vocabList);
}
return allVocab;
}
/// in student analytics page, the [defaultSelected] is the student
/// in class analytics page, the [defaultSelected] is the class
/// [defaultSelected] should never be a chat
/// the specific [selected] will be those items in the lists - chat, student or class
Future<VocabHeadwords> vocabHeadsByAnalyticsSelected({
required AnalyticsSelected? selected,
required AnalyticsSelected defaultSelected,
}) async {
Future<List<ConstructEvent>> eventsFuture;
String langCode;
if (defaultSelected.type == AnalyticsEntryType.space) {
// as long as a student isn't selected, we want the vocab events for the whole class
final Room? classRoom =
_pangeaController.matrixState.client.getRoomById(defaultSelected.id);
if (classRoom?.classSettings == null) {
throw Exception("classRoom missing in spaceMemberVocab");
}
langCode = classRoom!.classSettings!.targetLanguage;
eventsFuture = selected?.type == AnalyticsEntryType.student
? studentConstructs(selected!.id, langCode)
: spaceMemberVocab(defaultSelected.id);
} else if (defaultSelected.type == AnalyticsEntryType.student) {
// in this case, we're on an individual's own analytics page
if (selected?.type == AnalyticsEntryType.space ||
selected?.type == AnalyticsEntryType.student) {
langCode = _pangeaController.languageController
.activeL2Code(roomID: selected!.id)!;
eventsFuture = myConstructs(langCode);
} else {
if (_pangeaController.languageController.userL2 == null) {
throw Exception("userL2 missing in vocabHeadsByAnalyticsSelected");
}
langCode = _pangeaController.languageController.userL2!.langCode;
eventsFuture = myConstructs(langCode);
}
} else {
throw Exception("invalid defaultSelected.type - ${defaultSelected.type}");
}
return vocabHeadwordsWithTotals(langCode, await eventsFuture);
}
/// in student analytics page, the [defaultSelected] is the student
/// in class analytics page, the [defaultSelected] is the class
/// [defaultSelected] should never be a chat
/// the specific [selected] will be those items in the lists - chat, student or class
Future<List<ConstructEvent>> constuctEventsByAnalyticsSelected({
required AnalyticsSelected? selected,
required AnalyticsSelected defaultSelected,
required ConstructType constructType,
}) async {
late Future<List<ConstructEvent>> eventFutures;
String? langCode;
if (defaultSelected.type == AnalyticsEntryType.space) {
// as long as a student isn't selected, we want the vocab events for the whole class
final Room? space =
_pangeaController.matrixState.client.getRoomById(defaultSelected.id);
if (space == null) {
throw "No space available";
}
langCode = space.firstLanguageSettings?.targetLanguage;
if (langCode == null) {
throw "No target language available";
}
eventFutures = selected?.type == AnalyticsEntryType.student
? studentConstructs(selected!.id, langCode)
: spaceMemberVocab(defaultSelected.id);
} else if (defaultSelected.type == AnalyticsEntryType.student) {
// in this case, we're on an individual's own analytics page
if (selected?.type == AnalyticsEntryType.space ||
selected?.type == AnalyticsEntryType.student) {
langCode = _pangeaController.languageController
.activeL2Code(roomID: selected!.id)!;
eventFutures = myConstructs(langCode);
} else {
if (_pangeaController.languageController.userL2 == null) {
throw "userL2 missing in constuctEventsByAnalyticsSelected";
}
langCode = _pangeaController.languageController.userL2!.langCode;
eventFutures = myConstructs(langCode);
}
} else {
throw "invalid defaultSelected.type - ${defaultSelected.type}";
}
final List<ConstructEvent> events = (await eventFutures)
.where(
(element) => element.content.type == constructType,
)
final List<String> spaceChildrenIds = space.spaceChildren
.map((e) => e.roomId)
.where((e) => e != null)
.cast<String>()
.toList();
final List<String> chatIdsToFilterBy = [];
if (selected?.type == AnalyticsEntryType.room) {
chatIdsToFilterBy.add(selected!.id);
} else if (selected?.type == AnalyticsEntryType.privateChats) {
chatIdsToFilterBy.addAll(
_pangeaController.matrixState.client
.getRoomById(defaultSelected.id)
?.childrenAndGrandChildrenDirectChatIds ??
[],
);
} else if (defaultSelected.type == AnalyticsEntryType.space) {
chatIdsToFilterBy.addAll(
_pangeaController.matrixState.client
.getRoomById(defaultSelected.id)
?.childrenAndGrandChildren
.where((e) => e.roomId != null)
.map((e) => e.roomId!) ??
[],
);
}
if (chatIdsToFilterBy.isNotEmpty) {
for (final event in events) {
event.content.uses
.removeWhere((u) => !chatIdsToFilterBy.contains(u.chatId));
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));
}
events.removeWhere((e) => e.content.uses.isEmpty);
allConstructs.addAll(
constructList.where((e) => e.content.uses.isNotEmpty),
);
}
return events;
return type == null
? allConstructs
: allConstructs.where((e) => e.content.type == type).toList();
}
List<ConstructEvent> filterStudentConstructs(
List<ConstructEvent> unfilteredConstructs,
String? studentId,
) {
final List<ConstructEvent> filtered =
List<ConstructEvent>.from(unfilteredConstructs);
filtered.removeWhere((e) => e.event.senderId != studentId);
return filtered;
}
List<ConstructEvent> filterRoomConstructs(
List<ConstructEvent> 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),
);
return filtered;
}
List<ConstructEvent> filterPrivateChatConstructs(
List<ConstructEvent> 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),
),
);
return filtered;
}
List<ConstructEvent> filterSpaceConstructs(
List<ConstructEvent> unfilteredConstructs,
Room space,
) {
final List<String> chatIds = space.spaceChildren
.map((e) => e.roomId)
.where((e) => e != null)
.cast<String>()
.toList();
List<ConstructEvent> filtered =
List<ConstructEvent>.from(unfilteredConstructs);
filtered = filtered
.where((e) => e.content.uses.any((u) => chatIds.contains(u.chatId)))
.toList();
filtered.forEachIndexed(
(i, _) => filtered[i].content.uses.removeWhere(
(u) => !chatIds.contains(u.chatId),
),
);
return filtered;
}
List<ConstructEvent>? getConstructsLocal({
required TimeSpan timeSpan,
required ConstructType constructType,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
}) {
final cachedEntry = _cachedConstructs
.firstWhereOrNull(
(e) =>
e.timeSpan == timeSpan &&
e.type == constructType &&
e.defaultSelected.id == defaultSelected.id &&
e.defaultSelected.type == defaultSelected.type &&
e.selected?.id == selected?.id &&
e.selected?.type == selected?.type,
)
?.events;
return cachedEntry;
}
void cacheConstructs({
required ConstructType constructType,
required List<ConstructEvent> events,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
}) {
_cachedConstructs.add(
ConstructCacheEntry(
timeSpan: currentAnalyticsTimeSpan,
type: constructType,
events: events,
defaultSelected: defaultSelected,
selected: selected,
),
);
}
Future<List<ConstructEvent>> getMyConstructs({
required AnalyticsSelected defaultSelected,
required ConstructType constructType,
required String langCode,
AnalyticsSelected? selected,
}) async {
final List<ConstructEvent> unfilteredConstructs = await allMyConstructs(
langCode,
type: constructType,
);
final Room? space = selected?.type == AnalyticsEntryType.space
? _pangeaController.matrixState.client.getRoomById(selected!.id)
: null;
return filterConstructs(
unfilteredConstructs: unfilteredConstructs,
langCode: langCode,
space: space,
defaultSelected: defaultSelected,
selected: selected,
);
}
Future<List<ConstructEvent>> getSpaceConstructs({
required ConstructType constructType,
required Room space,
required AnalyticsSelected defaultSelected,
required String langCode,
AnalyticsSelected? selected,
}) async {
final List<ConstructEvent> 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,
required AnalyticsSelected defaultSelected,
Room? space,
AnalyticsSelected? selected,
}) async {
if ([AnalyticsEntryType.privateChats, AnalyticsEntryType.space]
.contains(selected?.type)) {
assert(space != null);
}
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),
);
}
unfilteredConstructs.removeWhere((e) => e.content.uses.isEmpty);
switch (selected?.type) {
case null:
return unfilteredConstructs;
case AnalyticsEntryType.student:
if (defaultSelected.type != AnalyticsEntryType.space) {
throw Exception(
"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);
case AnalyticsEntryType.room:
return filterRoomConstructs(unfilteredConstructs, selected?.id);
case AnalyticsEntryType.privateChats:
return defaultSelected.type == AnalyticsEntryType.student
? throw "private chat filtering not available for my analytics"
: filterPrivateChatConstructs(unfilteredConstructs, space!);
case AnalyticsEntryType.space:
return filterSpaceConstructs(unfilteredConstructs, space!);
default:
throw Exception("invalid filter type - ${selected?.type}");
}
}
Future<List<ConstructEvent>?> setConstructs({
required ConstructType constructType,
required AnalyticsSelected defaultSelected,
AnalyticsSelected? selected,
bool removeIT = false,
bool forceUpdate = false,
}) async {
final List<ConstructEvent>? local = getConstructsLocal(
timeSpan: currentAnalyticsTimeSpan,
constructType: constructType,
defaultSelected: defaultSelected,
selected: selected,
);
if (local != null && !forceUpdate) {
_constructs = local;
return _constructs;
}
if (settingConstructs) return _constructs;
settingConstructs = true;
await _pangeaController.matrixState.client.roomsLoading;
Room? space;
if (defaultSelected.type == AnalyticsEntryType.space) {
space = _pangeaController.matrixState.client.getRoomById(
defaultSelected.id,
);
}
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 (local == null) {
cacheConstructs(
constructType: constructType,
events: _constructs!,
defaultSelected: defaultSelected,
selected: selected,
);
}
settingConstructs = false;
return _constructs;
}
}
class ConstructCacheEntry {
final TimeSpan timeSpan;
final ConstructType type;
final List<ConstructEvent> events;
final AnalyticsSelected defaultSelected;
AnalyticsSelected? selected;
ConstructCacheEntry({
required this.timeSpan,
required this.type,
required this.events,
required this.defaultSelected,
this.selected,
});
}
class CacheModel {

View file

@ -47,7 +47,6 @@ class MyAnalyticsController {
final List<StudentAnalyticsEvent?> events = await analyticsEvents(spaces);
for (final event in events) {
debugPrint("adding to total ${event?.content.messages.length}");
if (event != null) {
event.handleNewMessage(messageRecord);
}
@ -69,9 +68,10 @@ class MyAnalyticsController {
return Future.wait(events);
}
Future<List<StudentAnalyticsEvent?>> allMyAnalyticsEvents() =>
Future<List<StudentAnalyticsEvent?>> allMyAnalyticsEvents() async =>
analyticsEvents(
_pangeaController.matrixState.client.classesAndExchangesImStudyingIn,
await _pangeaController
.matrixState.client.classesAndExchangesImStudyingIn,
);
Future<void> saveConstructsMixed(

View file

@ -24,13 +24,22 @@ extension PangeaClient on Client {
)
.toList();
List<Room> get classesAndExchangesImTeaching => rooms
.where(
(e) =>
(e.isPangeaClass || e.isExchange) &&
e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin,
)
.toList();
Future<List<Room>> get classesAndExchangesImTeaching async {
for (final Room space in rooms.where((room) => room.isSpace)) {
if (space.getState(EventTypes.RoomPowerLevels) == null) {
await space.postLoad();
}
}
final spaces = rooms
.where(
(e) =>
(e.isPangeaClass || e.isExchange) &&
e.ownPowerLevel == ClassDefaultValues.powerLevelOfAdmin,
)
.toList();
return spaces;
}
List<Room> get classesImIn => rooms
.where(
@ -40,20 +49,43 @@ extension PangeaClient on Client {
)
.toList();
List<Room> get classesAndExchangesImStudyingIn => rooms
.where(
(e) =>
(e.isPangeaClass || e.isExchange) &&
e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin,
)
.toList();
Future<List<Room>> get classesAndExchangesImStudyingIn async {
for (final Room space in rooms.where((room) => room.isSpace)) {
if (space.getState(EventTypes.RoomPowerLevels) == null) {
await space.postLoad();
}
}
final spaces = rooms
.where(
(e) =>
(e.isPangeaClass || e.isExchange) &&
e.ownPowerLevel < ClassDefaultValues.powerLevelOfAdmin,
)
.toList();
return spaces;
}
List<Room> get classesAndExchangesImIn =>
rooms.where((e) => e.isPangeaClass || e.isExchange).toList();
Future<List<String>> get teacherRoomIds async {
final List<String> adminRoomIds = [];
for (final Room adminSpace in (await classesAndExchangesImTeaching)) {
adminRoomIds.add(adminSpace.id);
final children = adminSpace.childrenAndGrandChildren;
final List<String> adminSpaceRooms = children
.where((e) => e.roomId != null)
.map((e) => e.roomId!)
.toList();
adminRoomIds.addAll(adminSpaceRooms);
}
return adminRoomIds;
}
Future<List<User>> get myTeachers async {
final List<User> teachers = [];
for (final classRoom in classesImIn) {
for (final classRoom in classesAndExchangesImIn) {
for (final teacher in await classRoom.teachers) {
if (!teachers.any((e) => e.id == teacher.id)) {
teachers.add(teacher);
@ -68,7 +100,7 @@ extension PangeaClient on Client {
]) async {
try {
final List<Future<void>> updateFutures = [];
for (final classRoom in classesImIn) {
for (final classRoom in classesAndExchangesImIn) {
updateFutures
.add(classRoom.updateMyLearningAnalyticsForClass(storageService));
}
@ -152,13 +184,14 @@ extension PangeaClient on Client {
return getRoomById(roomId)!;
}
PangeaRoomRules? get lastUpdatedRoomRules => classesAndExchangesImTeaching
.where((space) => space.rulesUpdatedAt != null)
.sorted(
(a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!),
)
.firstOrNull
?.pangeaRoomRules;
Future<PangeaRoomRules?> get lastUpdatedRoomRules async =>
(await classesAndExchangesImTeaching)
.where((space) => space.rulesUpdatedAt != null)
.sorted(
(a, b) => b.rulesUpdatedAt!.compareTo(a.rulesUpdatedAt!),
)
.firstOrNull
?.pangeaRoomRules;
ClassSettingsModel? get lastUpdatedClassSettings => classesImTeaching
.where((space) => space.classSettingsUpdatedAt != null)

View file

@ -170,6 +170,24 @@ extension PangeaRoom on Room {
}
//note this only will return rooms that the user has joined or been invited to
List<Room> get joinedChildren {
if (!isSpace) return [];
return spaceChildren
.where((child) => child.roomId != null)
.map(
(child) => client.getRoomById(child.roomId!),
)
.where((child) => child != null)
.cast<Room>()
.where(
(child) => child.membership == Membership.join,
)
.toList();
}
List<String> get joinedChildrenRoomIds =>
joinedChildren.map((child) => child.id).toList();
List<SpaceChild> get childrenAndGrandChildren {
if (!isSpace) return [];
final List<SpaceChild> kids = [];
@ -331,7 +349,6 @@ extension PangeaRoom on Room {
bool forcedUpdate = false,
}) async {
try {
debugPrint("getStudentAnalytics $studentId");
if (!isSpace) {
debugger(when: kDebugMode);
throw Exception("calling getStudentAnalyticsLocal on non-space room");
@ -475,7 +492,7 @@ extension PangeaRoom on Room {
if (storageService?.read(migratedAnalyticsKey) ?? false) return;
if (!isPangeaClass) {
if (!isPangeaClass && !isExchange) {
throw Exception(
"In updateMyLearningAnalyticsForClass with room that is not not a class",
);
@ -558,24 +575,21 @@ extension PangeaRoom on Room {
final List<RecentMessageRecord> msgs = [];
for (final event in timeline.events) {
if (event.senderId == client.userID &&
event.type == EventTypes.Message) {
if (event.content['msgtype'] == MessageTypes.Text) {
final PangeaMessageEvent pMsgEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: true,
);
msgs.add(
RecentMessageRecord(
eventId: event.eventId,
chatId: id,
useType: pMsgEvent.useType,
time: event.originServerTs,
),
);
} else {
debugger(when: kDebugMode);
}
event.type == EventTypes.Message &&
event.content['msgtype'] == MessageTypes.Text) {
final PangeaMessageEvent pMsgEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: true,
);
msgs.add(
RecentMessageRecord(
eventId: event.eventId,
chatId: id,
useType: pMsgEvent.useType,
time: event.originServerTs,
),
);
}
}
return msgs;
@ -702,7 +716,6 @@ extension PangeaRoom on Room {
Future<List<ConstructEvent>> get allConstructEvents async {
await postLoad();
return states[PangeaEventTypes.vocab]
?.values
.map((Event event) => ConstructEvent(event: event))
@ -753,7 +766,7 @@ extension PangeaRoom on Room {
.map((e) => e.id),
BotName.byEnvironment,
];
for (final teacher in await client.myTeachers) {
for (final teacher in (await client.myTeachers)) {
if (!toAdd.contains(teacher.id)) {
debugPrint("inviting ${teacher.id} to analytics room");
await invite(teacher.id);

View file

@ -1,9 +1,10 @@
import 'dart:developer';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/constants/model_keys.dart';
import '../enum/construct_type_enum.dart';
class ConstructUses {
@ -178,4 +179,14 @@ class OneConstructUse {
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!);
}
}

View file

@ -8,6 +8,7 @@ import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/models/choreo_record.dart';
import 'package:fluffychat/pangea/models/class_model.dart';
import 'package:fluffychat/pangea/models/message_data_models.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/models/pangea_representation_event.dart';
import 'package:fluffychat/pangea/utils/bot_name.dart';
import 'package:fluffychat/pangea/widgets/chat/message_audio_card.dart';
@ -470,6 +471,19 @@ class PangeaMessageEvent {
return langCode ?? LanguageKeys.unknownLanguage;
}
PangeaMatch? firstErrorStep(String lemma) {
final RepresentationEvent? repEvent = originalSent ?? originalWritten;
if (repEvent?.choreo == null) return null;
final PangeaMatch? step = repEvent!.choreo!.choreoSteps
.firstWhereOrNull(
(element) =>
element.acceptedOrIgnoredMatch?.match.shortMessage == lemma,
)
?.acceptedOrIgnoredMatch;
return step;
}
// List<SpanData> get activities =>
//each match is turned into an activity that other students can access
//they're not told the answer but have to find it themselves

View file

@ -1,18 +1,17 @@
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import '../../../../utils/date_time_extension.dart';
import '../../../widgets/avatar.dart';
import '../../../widgets/matrix.dart';
import '../../models/chart_analytics_model.dart';
import 'base_analytics_page.dart';
import 'base_analytics.dart';
import 'list_summary_analytics.dart';
class AnalyticsListTile extends StatelessWidget {
class AnalyticsListTile extends StatefulWidget {
const AnalyticsListTile({
super.key,
required this.model,
@ -20,9 +19,11 @@ class AnalyticsListTile extends StatelessWidget {
required this.avatar,
required this.type,
required this.id,
required this.allowNavigateOnSelect,
required this.selected,
required this.onTap,
required this.allowNavigateOnSelect,
this.enabled = true,
this.showSpaceAnalytics = true,
});
final Uri? avatar;
@ -30,78 +31,95 @@ class AnalyticsListTile extends StatelessWidget {
final AnalyticsEntryType type;
final String id;
final ChartAnalyticsModel? model;
final bool selected;
final bool allowNavigateOnSelect;
final void Function(AnalyticsSelected) onTap;
final bool selected;
final bool enabled;
final bool showSpaceAnalytics;
@override
AnalyticsListTileState createState() => AnalyticsListTileState();
}
class AnalyticsListTileState extends State<AnalyticsListTile> {
@override
Widget build(BuildContext context) {
final Room? room = Matrix.of(context).client.getRoomById(id);
final Room? room = Matrix.of(context).client.getRoomById(widget.id);
return Material(
color: selected
color: widget.selected
? Theme.of(context).colorScheme.secondaryContainer
: Colors.transparent,
child: ListTile(
leading: type == AnalyticsEntryType.privateChats
? CircleAvatar(
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
radius: Avatar.defaultSize / 2,
child: const Icon(Icons.forum),
)
: Avatar(
mxContent: avatar,
name: displayName,
littleIcon: room?.roomTypeIcon,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
softWrap: false,
style: TextStyle(
fontWeight: FontWeight.bold,
color: Theme.of(context).textTheme.bodyLarge!.color,
child: Opacity(
opacity: widget.enabled ? 1 : 0.5,
child: Tooltip(
message: widget.enabled ? "" : L10n.of(context)!.joinToView,
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,
),
),
),
),
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(
model?.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,
enabled: widget.enabled,
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: ListSummaryAnalytics(
chartAnalytics: model,
),
selected: selected,
onTap: () => (room?.isSpace ?? false) && allowNavigateOnSelect
? context.go(
'/rooms/analytics/${room!.id}',
)
: onTap(
AnalyticsSelected(
id,
type,
displayName,
),
),
trailing: (room?.isSpace ?? false) &&
type != AnalyticsEntryType.privateChats &&
allowNavigateOnSelect
? const Icon(Icons.chevron_right)
: null,
),
);
}

View file

@ -1,17 +1,14 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
class BarChartCard extends StatelessWidget {
const BarChartCard({
super.key,
required this.barChartTitle,
required this.barChart,
required this.legend,
required this.loadingData,
});
final String barChartTitle;
final BarChart? barChart;
final Widget legend;
final bool loadingData;
@ -28,11 +25,6 @@ class BarChartCard extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 6),
Text(
barChartTitle,
style: Theme.of(context).textTheme.bodyMedium,
),
const SizedBox(height: 14),
Expanded(
child: loadingData || barChart == null

View file

@ -0,0 +1,191 @@
import 'dart:async';
import 'package:fluffychat/pangea/enum/construct_type_enum.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:matrix/matrix.dart';
import '../../../widgets/matrix.dart';
import '../../controllers/pangea_controller.dart';
import '../../enum/bar_chart_view_enum.dart';
import '../../enum/time_span.dart';
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;
final StudentAnalyticsController? myAnalyticsController;
const BaseAnalyticsPage({
super.key,
required this.pageTitle,
required this.tabs,
required this.refreshData,
required this.alwaysSelected,
required this.defaultSelected,
this.myAnalyticsController,
});
@override
State<BaseAnalyticsPage> createState() => BaseAnalyticsController();
}
class BaseAnalyticsController extends State<BaseAnalyticsPage> {
final PangeaController pangeaController = MatrixState.pangeaController;
BarChartViewSelection? selectedView;
AnalyticsSelected? selected;
String? currentLemma;
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(),
);
}
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,
);
return data;
}
TimeSpan get currentTimeSpan =>
pangeaController.analytics.currentAnalyticsTimeSpan;
void navigate() {
if (currentLemma != null) {
setCurrentLemma(null);
} else if (selectedView != null) {
setSelectedView(null);
} else {
Navigator.of(context).pop();
}
}
void toggleSelection(AnalyticsSelected selectedParam) {
setState(() {
debugPrint("selectedParam.id is ${selectedParam.id}");
currentLemma = null;
selected = isSelected(selectedParam.id) ? null : selectedParam;
});
pangeaController.analytics.setConstructs(
constructType: ConstructType.grammar,
defaultSelected: widget.defaultSelected,
selected: selected,
removeIT: true,
);
Future.delayed(Duration.zero, () => setState(() {}));
}
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,
);
setState(() {});
}
void setSelectedView(BarChartViewSelection? view) {
currentLemma = null;
selectedView = view;
if (!enableSelection(selected)) {
toggleSelection(selected!);
}
setState(() {});
}
void setCurrentLemma(String? lemma) {
currentLemma = lemma;
setState(() {});
}
bool enableSelection(AnalyticsSelected? selectedParam) {
return selectedView == BarChartViewSelection.grammar &&
selectedParam?.type == AnalyticsEntryType.room
? Matrix.of(context)
.client
.getRoomById(selectedParam!.id)
?.membership ==
Membership.join
: true;
}
@override
Widget build(BuildContext context) {
return BaseAnalyticsView(controller: this);
}
}
class TabData {
AnalyticsEntryType type;
IconData icon;
List<TabItem> items;
bool allowNavigateOnSelect;
TabData({
required this.type,
required this.items,
required this.icon,
this.allowNavigateOnSelect = true,
});
}
class TabItem {
Uri? avatar;
String displayName;
String id;
TabItem({required this.avatar, required this.displayName, required this.id});
}
enum AnalyticsEntryType { student, room, space, privateChats }
class AnalyticsSelected {
String id;
AnalyticsEntryType type;
String displayName;
AnalyticsSelected(this.id, this.type, this.displayName);
}

View file

@ -1,365 +0,0 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import '../../../widgets/layouts/max_width_body.dart';
import '../../../widgets/matrix.dart';
import '../../controllers/pangea_controller.dart';
import '../../enum/bar_chart_view_enum.dart';
import '../../enum/time_span.dart';
import '../../models/chart_analytics_model.dart';
import 'analytics_list_tile.dart';
import 'construct_list.dart';
import 'messages_bar_chart.dart';
import 'time_span_menu_button.dart';
class BaseAnalyticsPage extends StatefulWidget {
final String pageTitle;
final TabData tabData1;
final TabData tabData2;
final Future Function(BuildContext) refreshData;
final AnalyticsSelected defaultAnalyticsSelected;
final AnalyticsSelected? alwaysSelected;
const BaseAnalyticsPage({
super.key,
required this.pageTitle,
required this.tabData1,
required this.tabData2,
required this.defaultAnalyticsSelected,
required this.refreshData,
required this.alwaysSelected,
});
@override
State<BaseAnalyticsPage> createState() => BaseAnalyticsController();
}
class BaseAnalyticsController extends State<BaseAnalyticsPage> {
final PangeaController _pangeaController = MatrixState.pangeaController;
AnalyticsSelected? selected;
BarChartViewSelection selectedView = BarChartViewSelection.grammar;
@override
void initState() {
super.initState();
}
bool isSelected(String chatOrStudentId) => chatOrStudentId == selected?.id;
ChartAnalyticsModel? chartData(
BuildContext context,
AnalyticsSelected? selectedParam,
) {
final AnalyticsSelected analyticsSelected =
selectedParam ?? widget.defaultAnalyticsSelected;
if (analyticsSelected.type == AnalyticsEntryType.privateChats) {
return _pangeaController.analytics.getAnalyticsLocal(
classId: analyticsSelected.id,
chatId: AnalyticsEntryType.privateChats.toString(),
);
}
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,
);
return data;
}
String barTitle(BuildContext context) =>
"${selectedView.string(context)}: ${selected == null ? widget.defaultAnalyticsSelected.displayName : selected!.displayName}";
TimeSpan get currentTimeSpan =>
_pangeaController.analytics.currentAnalyticsTimeSpan;
void toggleSelection(AnalyticsSelected selectedParam) {
setState(() {
debugPrint("selectedParam.id is ${selectedParam.id}");
selected = isSelected(selectedParam.id) ? null : selectedParam;
});
Future.delayed(Duration.zero, () => setState(() {}));
}
void toggleTimeSpan(BuildContext context, TimeSpan timeSpan) {
_pangeaController.analytics.setCurrentAnalyticsTimeSpan(timeSpan);
setState(() {});
widget.refreshData(context).then((value) => setState(() {}));
}
void toggleSelectedView(BarChartViewSelection view) {
selectedView = view;
setState(() {});
}
@override
Widget build(BuildContext context) => BaseAnalyticsView(controller: this);
}
class BaseAnalyticsView extends StatelessWidget {
const BaseAnalyticsView({
super.key,
required this.controller,
});
final BaseAnalyticsController controller;
Widget chartView(BuildContext context) {
switch (controller.selectedView) {
case BarChartViewSelection.messages:
return MessagesBarChart(
chartAnalytics: controller.chartData(context, controller.selected),
barChartTitle: controller.barTitle(context),
);
// case BarChartViewSelection.vocab:
// return ConstructList(
// selected: controller.selected,
// defaultSelected: controller.widget.defaultAnalyticsSelected,
// constructType: ConstructType.vocab,
// );
case BarChartViewSelection.grammar:
return ConstructList(
selected: controller.selected,
defaultSelected: controller.widget.defaultAnalyticsSelected,
constructType: ConstructType.grammar,
title: controller.barTitle(context),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: Text(
controller.widget.pageTitle,
style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge!.color,
fontSize: 18,
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.clip,
textAlign: TextAlign.center,
),
actions: [
for (final view in BarChartViewSelection.values)
Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: controller.selectedView == view
? AppConfig.primaryColor
: null,
),
child: IconButton(
isSelected: controller.selectedView == view,
icon: Icon(view.icon),
tooltip: view.string(context),
onPressed: () => controller.toggleSelectedView(view),
),
),
TimeSpanMenuButton(
value: controller.currentTimeSpan,
onChange: (TimeSpan value) =>
controller.toggleTimeSpan(context, value),
),
// ChartViewPickerButton(
// selected: controller.selectedView,
// onChange: controller.toggleSelectedView,
// ),
],
),
body: MaxWidthBody(
withScrolling: false,
child: Column(
children: [
Expanded(
flex: 1,
child: chartView(context),
),
Expanded(
flex: 1,
child: DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
tabs: [
Tab(
icon: Icon(
controller.widget.tabData1.icon,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
Tab(
icon: Icon(
controller.widget.tabData2.icon,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
Expanded(
child: SingleChildScrollView(
child: SizedBox(
height: max(
controller.widget.tabData1.items.length + 1,
controller.widget.tabData2.items.length,
) *
72,
child: TabBarView(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
...controller.widget.tabData1.items.map(
(item) => AnalyticsListTile(
avatar: item.avatar,
model: controller.chartData(
context,
AnalyticsSelected(
item.id,
controller.widget.tabData1.type,
"",
),
),
displayName: item.displayName,
id: item.id,
type: controller.widget.tabData1.type,
selected: controller.isSelected(item.id),
onTap: controller.toggleSelection,
allowNavigateOnSelect: controller.widget
.tabData1.allowNavigateOnSelect,
),
),
if (controller.widget.defaultAnalyticsSelected
.type ==
AnalyticsEntryType.space)
AnalyticsListTile(
avatar: null,
model: controller.chartData(
context,
AnalyticsSelected(
controller.widget
.defaultAnalyticsSelected.id,
AnalyticsEntryType.privateChats,
L10n.of(context)!.allPrivateChats,
),
),
displayName:
L10n.of(context)!.allPrivateChats,
id: controller
.widget.defaultAnalyticsSelected.id,
type: AnalyticsEntryType.privateChats,
selected: controller.isSelected(
controller
.widget.defaultAnalyticsSelected.id,
),
onTap: controller.toggleSelection,
allowNavigateOnSelect: false,
),
],
),
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: controller.widget.tabData2.items
.map(
(item) => AnalyticsListTile(
avatar: item.avatar,
model: controller.chartData(
context,
AnalyticsSelected(
item.id,
controller.widget.tabData2.type,
"",
),
),
displayName: item.displayName,
id: item.id,
type: controller.widget.tabData2.type,
selected:
controller.isSelected(item.id),
onTap: controller.toggleSelection,
allowNavigateOnSelect: controller.widget
.tabData2.allowNavigateOnSelect,
),
)
.toList(),
),
],
),
),
),
),
],
),
),
),
],
),
),
);
}
}
class TabData {
AnalyticsEntryType type;
IconData icon;
List<TabItem> items;
bool allowNavigateOnSelect;
TabData({
required this.type,
required this.items,
required this.icon,
this.allowNavigateOnSelect = true,
});
}
class TabItem {
Uri? avatar;
String displayName;
String id;
TabItem({required this.avatar, required this.displayName, required this.id});
}
enum AnalyticsEntryType { student, room, space, privateChats }
class AnalyticsSelected {
String id;
AnalyticsEntryType type;
String displayName;
AnalyticsSelected(this.id, this.type, this.displayName);
}

View file

@ -0,0 +1,302 @@
import 'dart:math';
import 'package:fluffychat/pangea/enum/bar_chart_view_enum.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/enum/time_span.dart';
import 'package:fluffychat/pangea/pages/analytics/analytics_list_tile.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/pangea/pages/analytics/construct_list.dart';
import 'package:fluffychat/pangea/pages/analytics/messages_bar_chart.dart';
import 'package:fluffychat/pangea/pages/analytics/time_span_menu_button.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
class BaseAnalyticsView extends StatelessWidget {
const BaseAnalyticsView({
super.key,
required this.controller,
});
final BaseAnalyticsController controller;
Widget chartView(BuildContext context) {
if (controller.selectedView == null) {
return const SizedBox();
}
switch (controller.selectedView!) {
case BarChartViewSelection.messages:
return MessagesBarChart(
chartAnalytics: controller.chartData(
context,
controller.selected,
),
);
case BarChartViewSelection.grammar:
return ConstructList(
constructType: ConstructType.grammar,
defaultSelected: controller.widget.defaultSelected,
selected: controller.selected,
controller: controller,
pangeaController: controller.pangeaController,
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: true,
title: RichText(
text: TextSpan(
style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge!.color,
fontSize: 18,
fontWeight: FontWeight.w700,
),
children: [
TextSpan(
text: controller.widget.pageTitle,
style: const TextStyle(decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()
..onTap = () => controller.selectedView != null
? controller.setSelectedView(null)
: null,
),
if (controller.selectedView != null)
const TextSpan(
text: " > ",
),
if (controller.selectedView != null)
TextSpan(
style: const TextStyle(decoration: TextDecoration.underline),
text: controller.selectedView!.string(context),
recognizer: TapGestureRecognizer()
..onTap = () => controller.currentLemma != null
? controller.setCurrentLemma(null)
: null,
),
if (controller.currentLemma != null)
const TextSpan(
text: " > ",
),
if (controller.currentLemma != null)
TextSpan(
text: controller.currentLemma,
style: const TextStyle(decoration: TextDecoration.underline),
recognizer: TapGestureRecognizer()..onTap = () {},
),
],
),
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: controller.navigate,
),
actions: [
TimeSpanMenuButton(
value: controller.currentTimeSpan,
onChange: (TimeSpan value) =>
controller.toggleTimeSpan(context, value),
),
],
),
body: MaxWidthBody(
withScrolling: false,
child: controller.selectedView != null
? Column(
children: [
Expanded(
flex: 1,
child: chartView(context),
),
Expanded(
flex: 1,
child: DefaultTabController(
length: 2,
child: Column(
children: [
TabBar(
tabs: [
...controller.widget.tabs.map(
(tab) => Tab(
icon: Icon(
tab.icon,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
),
),
],
),
Expanded(
child: SingleChildScrollView(
child: SizedBox(
height: max(
controller.widget.tabs[0].items.length +
1,
controller.widget.tabs[1].items.length,
) *
72,
child: TabBarView(
children: [
Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: [
...controller.widget.tabs[0].items.map(
(item) => AnalyticsListTile(
avatar: item.avatar,
model: controller.chartData(
context,
AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
"",
),
),
displayName: item.displayName,
id: item.id,
type:
controller.widget.tabs[0].type,
selected:
controller.isSelected(item.id),
enabled: controller.enableSelection(
AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
"",
),
),
showSpaceAnalytics: false,
onTap: (_) =>
controller.toggleSelection(
AnalyticsSelected(
item.id,
controller.widget.tabs[0].type,
item.displayName,
),
),
allowNavigateOnSelect: controller
.widget
.tabs[0]
.allowNavigateOnSelect,
),
),
if (controller
.widget.defaultSelected.type ==
AnalyticsEntryType.space)
AnalyticsListTile(
avatar: null,
model: controller.chartData(
context,
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(
controller
.widget.defaultSelected.id,
),
onTap: controller.toggleSelection,
),
],
),
Column(
crossAxisAlignment:
CrossAxisAlignment.stretch,
children: controller.widget.tabs[1].items
.map(
(item) => AnalyticsListTile(
avatar: item.avatar,
model: controller.chartData(
context,
AnalyticsSelected(
item.id,
controller
.widget.tabs[1].type,
"",
),
),
displayName: item.displayName,
id: item.id,
type: controller
.widget.tabs[1].type,
selected: controller
.isSelected(item.id),
onTap: controller.toggleSelection,
allowNavigateOnSelect: controller
.widget
.tabs[1]
.allowNavigateOnSelect,
),
)
.toList(),
),
],
),
),
),
),
],
),
),
),
],
)
: Column(
children: [
const Divider(height: 1),
ListTile(
title: const Text("Error Analytics"),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor:
Theme.of(context).textTheme.bodyLarge!.color,
child: Icon(BarChartViewSelection.grammar.icon),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => controller.setSelectedView(
BarChartViewSelection.grammar,
),
),
const Divider(height: 1),
ListTile(
title: const Text("Message Analytics"),
leading: CircleAvatar(
backgroundColor:
Theme.of(context).scaffoldBackgroundColor,
foregroundColor:
Theme.of(context).textTheme.bodyLarge!.color,
child: Icon(BarChartViewSelection.messages.icon),
),
trailing: const Icon(Icons.chevron_right),
onTap: () => controller.setSelectedView(
BarChartViewSelection.messages,
),
),
const Divider(height: 1),
],
),
),
);
}
}

View file

@ -2,8 +2,10 @@ import 'dart:async';
import 'dart:developer';
import 'package:fluffychat/pangea/constants/pangea_event_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';
@ -33,9 +35,43 @@ class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
StreamSubscription<Event>? stateSub;
Timer? refreshTimer;
List<Room> chats = [];
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();
@ -43,6 +79,7 @@ class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
if (classRoom == null || (!(classRoom?.isSpace ?? false))) {
context.go('/rooms');
}
stateSub = _pangeaController.matrixState.client.onRoomState.stream
.where(
(event) =>
@ -59,13 +96,16 @@ class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
await classRoom?.requestParticipants();
if (classRoom != null) {
final response = await Matrix.of(context).client.getSpaceHierarchy(
classRoom!.id,
maxDepth: 1,
);
students = classRoom!.students;
chats = classRoom!.spaceChildren
.where((element) => element.roomId != null)
.map((e) => Matrix.of(context).client.getRoomById(e.roomId!))
.where((r) => r != null)
.cast<Room>()
chats = response.rooms
.where((room) => room.roomId != classRoom!.id)
.toList();
chats.sort((a, b) => a.roomType == 'm.space' ? -1 : 1);
}
setState(() {
@ -131,7 +171,7 @@ class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
classRoom: classRoom,
chatId: chat.id,
chatId: chat.roomId,
forceUpdate: forceUpdate,
),
);
@ -154,18 +194,4 @@ class ClassAnalyticsV2Controller extends State<ClassAnalyticsPage> {
debugger(when: kDebugMode);
}
}
String? get classId => GoRouterState.of(context).pathParameters['classid'];
Room? _classRoom;
Room? get classRoom {
_classRoom ??= classId != null
? Matrix.of(context).client.getRoomById(classId!)
: null;
return _classRoom;
}
String className(BuildContext context) {
return classRoom?.name ?? "";
}
}

View file

@ -1,9 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import '../../../../utils/matrix_sdk_extensions/matrix_locals.dart';
import '../base_analytics_page.dart';
import '../base_analytics.dart';
import 'class_analytics.dart';
class ClassAnalyticsView extends StatelessWidget {
@ -21,10 +19,9 @@ class ClassAnalyticsView extends StatelessWidget {
items: controller.chats
.map(
(room) => TabItem(
avatar: room.avatar,
displayName:
room.getLocalizedDisplayname(MatrixLocals(L10n.of(context)!)),
id: room.id,
avatar: room.avatarUrl,
displayName: room.name ?? "",
id: room.roomId,
),
)
.toList(),
@ -46,15 +43,14 @@ class ClassAnalyticsView extends StatelessWidget {
return controller.classId != null
? BaseAnalyticsPage(
pageTitle: pageTitle,
tabData1: tab1,
tabData2: tab2,
defaultAnalyticsSelected: AnalyticsSelected(
tabs: [tab1, tab2],
refreshData: controller.getChatAndStudentAnalytics,
alwaysSelected: AnalyticsSelected(
controller.classId!,
AnalyticsEntryType.space,
controller.className(context),
),
refreshData: controller.getChatAndStudentAnalytics,
alwaysSelected: AnalyticsSelected(
defaultSelected: AnalyticsSelected(
controller.classId!,
AnalyticsEntryType.space,
controller.className(context),

View file

@ -1,15 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/extensions/client_extension.dart';
import 'package:fluffychat/pangea/pages/analytics/analytics_list_tile.dart';
import 'package:fluffychat/pangea/pages/analytics/time_span_menu_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:go_router/go_router.dart';
import '../../../../widgets/matrix.dart';
import '../../../enum/time_span.dart';
import '../base_analytics_page.dart';
import '../base_analytics.dart';
import 'class_list.dart';
class AnalyticsClassListView extends StatelessWidget {
@ -18,8 +16,6 @@ class AnalyticsClassListView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final List<Room> classesAndExchanges =
Matrix.of(context).client.classesAndExchangesImTeaching;
return Scaffold(
appBar: AppBar(
centerTitle: true,
@ -48,25 +44,25 @@ class AnalyticsClassListView extends StatelessWidget {
),
body: Column(
children: [
// MessagesBarChart(
// chartAnalytics: controller.chartData(context),
// barChartTitle: "",
// ),
Flexible(
child: ListView.builder(
itemCount: classesAndExchanges.length,
itemBuilder: (context, i) => AnalyticsListTile(
avatar: classesAndExchanges[i].avatar,
model: controller.pangeaController.analytics
.getAnalyticsLocal(classId: classesAndExchanges[i].id),
displayName: classesAndExchanges[i].name,
id: classesAndExchanges[i].id,
type: AnalyticsEntryType.space,
selected: false,
onTap: (selected) => context.go(
'/rooms/analytics/${selected.id}',
child: FutureBuilder(
future: Matrix.of(context).client.classesAndExchangesImTeaching,
builder: (context, snapshot) => ListView.builder(
itemCount: snapshot.hasData ? snapshot.data?.length ?? 0 : 0,
itemBuilder: (context, i) => AnalyticsListTile(
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,
onTap: (selected) => context.go(
'/rooms/analytics/${selected.id}',
),
allowNavigateOnSelect: true,
selected: false,
),
allowNavigateOnSelect: true,
),
),
),

View file

@ -1,30 +1,37 @@
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/pangea_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/models/construct_analytics_event.dart';
import 'package:fluffychat/pangea/models/constructs_analytics_model.dart';
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/models/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/pangea_representation_event.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
import 'package:fluffychat/utils/string_color.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/constants/match_rule_ids.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics_page.dart';
import 'package:fluffychat/widgets/matrix.dart';
import '../../constants/pangea_event_types.dart';
import '../../models/construct_analytics_event.dart';
import '../../utils/error_handler.dart';
class ConstructList extends StatefulWidget {
final AnalyticsSelected? selected;
final AnalyticsSelected defaultSelected;
final ConstructType constructType;
final String title;
final AnalyticsSelected defaultSelected;
final AnalyticsSelected? selected;
final BaseAnalyticsController controller;
final PangeaController pangeaController;
const ConstructList({
super.key,
required this.selected,
required this.defaultSelected,
required this.constructType,
required this.title,
required this.defaultSelected,
required this.controller,
required this.pangeaController,
this.selected,
});
@override
@ -32,77 +39,27 @@ class ConstructList extends StatefulWidget {
}
class ConstructListState extends State<ConstructList> {
List<ConstructEvent> constructs = [];
bool initialized = false;
String? langCode;
String? error;
StreamSubscription<Event>? stateSub;
Timer? refreshTimer;
@override
void initState() {
super.initState();
_updateConstructs();
stateSub = MatrixState
.pangeaController.matrixState.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);
}
void onStateUpdate(Event newState) {
if (!(refreshTimer?.isActive ?? false)) {
refreshTimer = Timer(
const Duration(seconds: 3),
() => _updateConstructs(),
);
}
widget.pangeaController.analytics
.setConstructs(
constructType: widget.constructType,
removeIT: true,
defaultSelected: widget.defaultSelected,
selected: widget.selected,
forceUpdate: true,
)
.then((_) => setState(() => initialized = true));
}
@override
void dispose() {
super.dispose();
refreshTimer?.cancel();
stateSub?.cancel();
}
@override
void didUpdateWidget(ConstructList oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.selected?.id != oldWidget.selected?.id) {
_updateConstructs();
}
}
void _updateConstructs() {
setState(() {
initialized = false;
});
MatrixState.pangeaController.analytics
.constuctEventsByAnalyticsSelected(
selected: widget.selected,
defaultSelected: widget.defaultSelected,
constructType: widget.constructType,
)
.then((value) {
setState(() {
constructs = value;
initialized = true;
error = null;
});
}).onError((error, stackTrace) {
ErrorHandler.logError(e: error, s: stackTrace);
setState(() {
constructs = [];
initialized = true;
error = error?.toString();
});
});
}
@override
@ -113,20 +70,12 @@ class ConstructListState extends State<ConstructList> {
)
: Column(
children: [
Text(
widget.title,
style: Theme.of(context).textTheme.bodyMedium,
),
ConstructListView(
constructs: constructs.where((element) {
debugPrint("element type is ${element.content.type}");
return element.content.lemma !=
"Try interactive translation" &&
element.content.lemma != "itStart" &&
element.content.lemma !=
MatchRuleIds.interactiveTranslation;
}).toList(),
init: initialized,
controller: widget.controller,
pangeaController: widget.pangeaController,
defaultSelected: widget.defaultSelected,
selected: widget.selected,
),
],
);
@ -142,39 +91,404 @@ class ConstructListState extends State<ConstructList> {
// title = construct.content.lemma
// subtitle = total uses, equal to construct.content.uses.length
// list has a fixed height of 400 and is scrollable
class ConstructListView extends StatelessWidget {
final List<ConstructEvent> constructs;
class ConstructListView extends StatefulWidget {
// final List<ConstructEvent> constructs;
final bool init;
final BaseAnalyticsController controller;
final PangeaController pangeaController;
final AnalyticsSelected defaultSelected;
final AnalyticsSelected? selected;
const ConstructListView({
super.key,
required this.constructs,
required this.init,
required this.controller,
required this.pangeaController,
required this.defaultSelected,
this.selected,
});
@override
State<StatefulWidget> createState() => ConstructListViewState();
}
class ConstructListViewState extends State<ConstructListView> {
final Map<String, Timeline> _timelinesCache = {};
final Map<String, PangeaMessageEvent> _msgEventCache = {};
final List<PangeaMessageEvent> _msgEvents = [];
bool fetchingUses = false;
StreamSubscription<Event>? stateSub;
Timer? refreshTimer;
@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();
},
);
}
@override
void dispose() {
super.dispose();
refreshTimer?.cancel();
stateSub?.cancel();
}
@override
void didUpdateWidget(ConstructListView oldWidget) {
super.didUpdateWidget(oldWidget);
fetchUses();
}
int get lemmaIndex =>
constructs?.indexWhere(
(element) => element.content.lemma == widget.controller.currentLemma,
) ??
-1;
Future<PangeaMessageEvent?> getMessageEvent(
OneConstructUse use,
) async {
final Client client = Matrix.of(context).client;
PangeaMessageEvent msgEvent;
if (_msgEventCache.containsKey(use.msgId!)) {
return _msgEventCache[use.msgId!]!;
}
final Room? msgRoom = use.getRoom(client);
if (msgRoom == null || use.msgId == null) {
return null;
}
Timeline? timeline;
if (_timelinesCache.containsKey(use.chatId)) {
timeline = _timelinesCache[use.chatId];
} else {
timeline = await msgRoom.getTimeline();
_timelinesCache[use.chatId] = timeline;
}
final Event? event = await use.getEvent(client);
if (event == null || timeline == null) {
return null;
}
msgEvent = PangeaMessageEvent(
event: event,
timeline: timeline,
ownMessage: event.senderId == client.userID,
);
_msgEventCache[use.msgId!] = msgEvent;
return msgEvent;
}
Future<void> fetchUses() async {
if (fetchingUses) return;
if (currentConstruct == null) {
setState(() => _msgEvents.clear());
return;
}
setState(() => fetchingUses = true);
final List<OneConstructUse> uses = currentConstruct!.content.uses;
_msgEvents.clear();
for (final OneConstructUse use in uses) {
final PangeaMessageEvent? msgEvent = await getMessageEvent(use);
final RepresentationEvent? repEvent =
msgEvent?.originalSent ?? msgEvent?.originalWritten;
if (repEvent?.choreo == null) {
continue;
}
_msgEvents.add(msgEvent!);
}
setState(() => fetchingUses = false);
}
List<ConstructEvent>? get constructs =>
widget.pangeaController.analytics.constructs;
ConstructEvent? get currentConstruct => constructs?.firstWhereOrNull(
(element) => element.content.lemma == widget.controller.currentLemma,
);
@override
Widget build(BuildContext context) {
if (!init) {
if (!widget.init || fetchingUses) {
return const Expanded(
child: Center(child: CircularProgressIndicator()),
);
}
if (constructs.isEmpty) {
if ((constructs?.isEmpty ?? true) ||
(widget.controller.currentLemma != null && currentConstruct == null)) {
return Expanded(
child: Center(child: Text(L10n.of(context)!.noDataFound)),
);
}
return Expanded(
child: ListView.builder(
itemCount: constructs.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(constructs[index].content.lemma),
subtitle: Text(
'${L10n.of(context)!.total} ${constructs[index].content.uses.length}',
return widget.controller.currentLemma == null
? Expanded(
child: ListView.builder(
itemCount: constructs!.length,
itemBuilder: (context, index) {
return ListTile(
title: Text(
constructs![index].content.lemma,
),
subtitle: Text(
'${L10n.of(context)!.total} ${constructs![index].content.uses.length}',
),
onTap: () {
final String lemma = constructs![index].content.lemma;
widget.controller.setCurrentLemma(lemma);
fetchUses();
},
);
},
),
)
: Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (constructs![lemmaIndex].content.uses.length >
_msgEvents.length)
const Center(
child: Padding(
padding: EdgeInsets.all(8.0),
child: Text(
"Some data may be missing from rooms in which you are not a member.",
),
),
),
Expanded(
child: ListView.separated(
separatorBuilder: (context, index) =>
const Divider(height: 1),
itemCount: _msgEvents.length,
itemBuilder: (context, index) {
return ConstructMessage(
msgEvent: _msgEvents[index],
lemma: widget.controller.currentLemma!,
);
},
),
),
],
),
);
},
}
}
class ConstructMessage extends StatelessWidget {
final PangeaMessageEvent msgEvent;
final String lemma;
const ConstructMessage({
super.key,
required this.msgEvent,
required this.lemma,
});
@override
Widget build(BuildContext context) {
final PangeaMatch? errorMessage = msgEvent.firstErrorStep(lemma);
if (errorMessage == null) {
return const SizedBox.shrink();
}
final String? chosen = errorMessage.match.choices
?.firstWhereOrNull(
(element) => element.selected == true,
)
?.value;
if (chosen == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.all(20),
child: Row(
children: [
ConstructMessageMetadata(msgEvent: msgEvent),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FutureBuilder<User?>(
future: msgEvent.event.fetchSenderUser(),
builder: (context, snapshot) {
final displayname = snapshot.data?.calcDisplayname() ??
msgEvent.event.senderFromMemoryOrFallback
.calcDisplayname();
return Text(
displayname,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: (Theme.of(context).brightness ==
Brightness.light
? displayname.color
: displayname.lightColorText),
),
);
},
),
ConstructMessageBubble(
errorText: errorMessage.match.fullText,
replacementText: chosen,
start: errorMessage.match.offset,
end:
errorMessage.match.offset + errorMessage.match.length,
),
],
),
],
),
),
],
),
);
}
}
class ConstructMessageBubble extends StatelessWidget {
final String errorText;
final String replacementText;
final int? start;
final int? end;
const ConstructMessageBubble({
super.key,
required this.errorText,
required this.replacementText,
this.start,
this.end,
});
@override
Widget build(BuildContext context) {
final defaultStyle = TextStyle(
color: Theme.of(context).colorScheme.onBackground,
fontSize: AppConfig.messageFontSize * AppConfig.fontSizeFactor,
height: 1.3,
);
return IntrinsicWidth(
child: Material(
color: Theme.of(context).colorScheme.primaryContainer,
clipBehavior: Clip.antiAlias,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: Radius.circular(4),
topRight: Radius.circular(AppConfig.borderRadius),
bottomLeft: Radius.circular(AppConfig.borderRadius),
bottomRight: Radius.circular(AppConfig.borderRadius),
),
),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
),
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
child: RichText(
text: (start == null || end == null)
? TextSpan(
text: errorText,
style: defaultStyle,
)
: TextSpan(
children: [
TextSpan(
text: errorText.substring(0, start),
style: defaultStyle,
),
TextSpan(
text: errorText.substring(start!, end),
style: defaultStyle.merge(
TextStyle(
backgroundColor: Colors.red.withOpacity(0.25),
decoration: TextDecoration.lineThrough,
decorationThickness: 2.5,
),
),
),
const TextSpan(text: " "),
TextSpan(
text: replacementText,
style: defaultStyle.merge(
TextStyle(
backgroundColor: Colors.green.withOpacity(0.25),
),
),
),
TextSpan(
text: errorText.substring(end!),
style: defaultStyle,
),
],
),
),
),
),
);
}
}
class ConstructMessageMetadata extends StatelessWidget {
final PangeaMessageEvent msgEvent;
const ConstructMessageMetadata({
super.key,
required this.msgEvent,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 30, 0),
child: Column(
children: [
Text(
msgEvent.event.originServerTs.localizedTime(context),
style: TextStyle(fontSize: 13 * AppConfig.fontSizeFactor),
),
Text(msgEvent.event.room.name),
],
),
);
}

View file

@ -16,12 +16,10 @@ import 'messages_legend_widget.dart';
class MessagesBarChart extends StatefulWidget {
final ChartAnalyticsModel? chartAnalytics;
final String barChartTitle;
const MessagesBarChart({
super.key,
required this.chartAnalytics,
required this.barChartTitle,
});
@override
@ -95,7 +93,6 @@ class MessagesBarChartState extends State<MessagesBarChart> {
);
return BarChartCard(
barChartTitle: widget.barChartTitle,
barChart: barChart,
loadingData: widget.chartAnalytics == null,
legend: const MessagesLegendsListWidget(),

View file

@ -1,18 +1,19 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
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';
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_page.dart';
import '../base_analytics.dart';
import 'student_analytics_view.dart';
class StudentAnalyticsPage extends StatefulWidget {
@ -24,16 +25,42 @@ class StudentAnalyticsPage extends StatefulWidget {
class StudentAnalyticsController extends State<StudentAnalyticsPage> {
final PangeaController _pangeaController = MatrixState.pangeaController;
AnalyticsSelected? selected;
StreamSubscription<Event>? stateSub;
Timer? refreshTimer;
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() {
_pangeaController.matrixState.client
void dispose() {
super.dispose();
refreshTimer?.cancel();
stateSub?.cancel();
}
Future<void> initialize() async {
await _pangeaController.matrixState.client
.updateMyLearningAnalyticsForAllClassesImIn(
_pangeaController.pStoreService,
);
super.initState();
await getClassAndChatAnalytics(context);
stateSub = _pangeaController.matrixState.client.onRoomState.stream
.where(
(event) =>
event.type == PangeaEventTypes.studentAnalyticsSummary &&
event.senderId == userId,
)
.listen(onStateUpdate);
}
@override
@ -43,55 +70,84 @@ class StudentAnalyticsController extends State<StudentAnalyticsPage> {
// but this is computationally expensive!
// key: UniqueKey(),
shimmerChild: const ListPlaceholder(),
onFinish: () {
getClassAndChatAnalytics(context);
},
onFinish: initialize,
child: StudentAnalyticsView(this),
);
}
Future<void> getClassAndChatAnalytics(BuildContext context) async {
Future<void> getClassAndChatAnalytics(
BuildContext context, [
forceUpdate = false,
]) async {
final List<Future<ChartAnalyticsModel?>> analyticsFutures = [];
for (final chat in chats(context)) {
for (final chat in (await getChats())) {
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
chatId: chat.id,
studentId: userId,
forceUpdate: forceUpdate,
),
);
}
for (final space in spaces(context)) {
for (final space in (await getSpaces())) {
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(
classRoom: space,
studentId: userId,
forceUpdate: forceUpdate,
),
);
}
analyticsFutures.add(
_pangeaController.analytics.getAnalytics(studentId: userId),
_pangeaController.analytics.getAnalytics(
studentId: userId,
forceUpdate: forceUpdate,
),
);
await Future.wait(analyticsFutures);
setState(() {});
}
List<Room> spaces(BuildContext context) {
Future<List<Room>> getSpaces() async {
final List<Room> rooms = await _pangeaController
.matrixState.client.classesAndExchangesImStudyingIn;
setState(() => _spaces = rooms);
return rooms;
}
List<Room>? get spaces {
try {
return _pangeaController
.matrixState.client.classesAndExchangesImStudyingIn;
if (_spaces.isNotEmpty) return _spaces;
getSpaces();
return _spaces;
} catch (err) {
debugger(when: kDebugMode);
return [];
}
}
List<Room> chats(BuildContext context) {
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 {
return Matrix.of(context)
.client
.rooms
.where((r) => !r.isSpace && !r.isAnalyticsRoom)
.toList();
if (_chats.isNotEmpty) return _chats;
getChats();
return _chats;
} catch (err) {
debugger(when: kDebugMode);
return [];

View file

@ -1,10 +1,8 @@
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/l10n.dart';
import 'package:matrix/matrix.dart';
import '../../../../utils/matrix_sdk_extensions/matrix_locals.dart';
import '../base_analytics_page.dart';
import '../base_analytics.dart';
import 'student_analytics.dart';
class StudentAnalyticsView extends StatelessWidget {
@ -13,14 +11,11 @@ class StudentAnalyticsView extends StatelessWidget {
@override
Widget build(BuildContext context) {
final List<Room> chats = controller.chats(context);
final List<Room> spaces = controller.spaces(context);
final String pageTitle = L10n.of(context)!.myLearning;
final TabData chatTabData = TabData(
type: AnalyticsEntryType.room,
icon: Icons.chat_bubble_outline,
items: chats
items: (controller.chats ?? [])
.map(
(c) => TabItem(
avatar: c.avatar,
@ -35,7 +30,7 @@ class StudentAnalyticsView extends StatelessWidget {
final TabData classTabData = TabData(
type: AnalyticsEntryType.space,
icon: Icons.workspaces,
items: spaces
items: (controller.spaces ?? [])
.map(
(c) => TabItem(
avatar: c.avatar,
@ -51,15 +46,15 @@ class StudentAnalyticsView extends StatelessWidget {
return controller.userId != null
? BaseAnalyticsPage(
pageTitle: pageTitle,
tabData1: chatTabData,
tabData2: classTabData,
defaultAnalyticsSelected: AnalyticsSelected(
tabs: [chatTabData, classTabData],
refreshData: controller.getClassAndChatAnalytics,
alwaysSelected: AnalyticsSelected(
controller.userId!,
AnalyticsEntryType.student,
L10n.of(context)!.allChatsAndClasses,
),
refreshData: controller.getClassAndChatAnalytics,
alwaysSelected: AnalyticsSelected(
myAnalyticsController: controller,
defaultSelected: AnalyticsSelected(
controller.userId!,
AnalyticsEntryType.student,
L10n.of(context)!.allChatsAndClasses,

View file

@ -1,174 +1,174 @@
import 'package:flutter/material.dart';
// import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
// import 'package:fl_chart/fl_chart.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/models/headwords.dart';
import 'package:fluffychat/pangea/pages/analytics/base_analytics_page.dart';
import 'package:fluffychat/widgets/matrix.dart';
import 'bar_chart_card.dart';
import 'messages_legend_widget.dart';
// import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
// import 'package:fluffychat/pangea/models/headwords.dart';
// import 'package:fluffychat/pangea/pages/analytics/base_analytics_page.dart';
// import 'package:fluffychat/widgets/matrix.dart';
// import 'bar_chart_card.dart';
// import 'messages_legend_widget.dart';
class VocabBarChart extends StatefulWidget {
final AnalyticsSelected? selected;
final AnalyticsSelected defaultSelected;
// class VocabBarChart extends StatefulWidget {
// final AnalyticsSelected? selected;
// final AnalyticsSelected defaultSelected;
const VocabBarChart({
super.key,
required this.selected,
required this.defaultSelected,
});
// const VocabBarChart({
// super.key,
// required this.selected,
// required this.defaultSelected,
// });
@override
State<StatefulWidget> createState() => VocabBarChartState();
}
// @override
// State<StatefulWidget> createState() => VocabBarChartState();
// }
class VocabBarChartState extends State<VocabBarChart> {
final double barSpace = 16;
// class VocabBarChartState extends State<VocabBarChart> {
// final double barSpace = 16;
final PangeaController _pangeaController = MatrixState.pangeaController;
// final PangeaController _pangeaController = MatrixState.pangeaController;
@override
initState() {
super.initState();
}
// @override
// initState() {
// super.initState();
// }
@override
Widget build(BuildContext context) {
return FutureBuilder<VocabHeadwords>(
future: _pangeaController.analytics.vocabHeadsByAnalyticsSelected(
selected: widget.selected,
defaultSelected: widget.defaultSelected,
),
builder: ((context, snapshot) => BarChartCard(
barChartTitle: (widget.selected != null
? widget.selected!
: widget.defaultSelected)
.displayName,
barChart: snapshot.hasData
? buildBarChart(context, snapshot.data!)
: null,
loadingData: snapshot.connectionState != ConnectionState.done,
legend: const MessagesLegendsListWidget(),
)),
);
}
// @override
// Widget build(BuildContext context) {
// return FutureBuilder<VocabHeadwords>(
// future: _pangeaController.analytics.vocabHeadsByAnalyticsSelected(
// selected: widget.selected,
// defaultSelected: widget.defaultSelected,
// ),
// builder: ((context, snapshot) => BarChartCard(
// barChartTitle: (widget.selected != null
// ? widget.selected!
// : widget.defaultSelected)
// .displayName,
// barChart: snapshot.hasData
// ? buildBarChart(context, snapshot.data!)
// : null,
// loadingData: snapshot.connectionState != ConnectionState.done,
// legend: const MessagesLegendsListWidget(),
// )),
// );
// }
TextStyle titleTextStyle(BuildContext context) => TextStyle(
color: Theme.of(context).textTheme.bodyLarge!.color,
fontSize: 10,
);
// TextStyle titleTextStyle(BuildContext context) => TextStyle(
// color: Theme.of(context).textTheme.bodyLarge!.color,
// fontSize: 10,
// );
BarChart buildBarChart(BuildContext context, VocabHeadwords vocabHeadwords) {
final flLine = FlLine(
color: Theme.of(context).dividerColor,
strokeWidth: 1,
);
// BarChart buildBarChart(BuildContext context, VocabHeadwords vocabHeadwords) {
// final flLine = FlLine(
// color: Theme.of(context).dividerColor,
// strokeWidth: 1,
// );
return BarChart(
BarChartData(
alignment: BarChartAlignment.spaceEvenly,
barTouchData: BarTouchData(
enabled: false,
),
// barTouchData: barTouchData,
titlesData: FlTitlesData(
show: true,
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 28,
getTitlesWidget: (double value, TitleMeta meta) =>
SideTitleWidget(
axisSide: meta.axisSide,
child: Text(
vocabHeadwords.lists[value.toInt()].name,
style: titleTextStyle(context),
),
),
),
),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
getTitlesWidget: (double value, TitleMeta meta) {
Widget textWidget;
if (value != meta.max) {
textWidget =
Text(meta.formattedValue, style: titleTextStyle(context));
} else {
textWidget = const Icon(Icons.abc_outlined, size: 14);
}
return SideTitleWidget(
axisSide: meta.axisSide,
child: textWidget,
);
},
),
),
topTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
gridData: FlGridData(
show: true,
// checkToShowHorizontalLine: (value) => value % 10 == 0,
checkToShowHorizontalLine: (value) => true,
getDrawingHorizontalLine: (value) => flLine,
checkToShowVerticalLine: (value) => false,
getDrawingVerticalLine: (value) => flLine,
),
borderData: FlBorderData(
show: false,
),
groupsSpace: barSpace,
barGroups: barChartGroupData(vocabHeadwords),
backgroundColor: Colors.transparent,
),
swapAnimationDuration: const Duration(milliseconds: 250),
);
}
// return BarChart(
// BarChartData(
// alignment: BarChartAlignment.spaceEvenly,
// barTouchData: BarTouchData(
// enabled: false,
// ),
// // barTouchData: barTouchData,
// titlesData: FlTitlesData(
// show: true,
// bottomTitles: AxisTitles(
// sideTitles: SideTitles(
// showTitles: true,
// reservedSize: 28,
// getTitlesWidget: (double value, TitleMeta meta) =>
// SideTitleWidget(
// axisSide: meta.axisSide,
// child: Text(
// vocabHeadwords.lists[value.toInt()].name,
// style: titleTextStyle(context),
// ),
// ),
// ),
// ),
// leftTitles: AxisTitles(
// sideTitles: SideTitles(
// showTitles: true,
// reservedSize: 40,
// getTitlesWidget: (double value, TitleMeta meta) {
// Widget textWidget;
// if (value != meta.max) {
// textWidget =
// Text(meta.formattedValue, style: titleTextStyle(context));
// } else {
// textWidget = const Icon(Icons.abc_outlined, size: 14);
// }
// return SideTitleWidget(
// axisSide: meta.axisSide,
// child: textWidget,
// );
// },
// ),
// ),
// topTitles: AxisTitles(
// sideTitles: SideTitles(showTitles: false),
// ),
// rightTitles: AxisTitles(
// sideTitles: SideTitles(showTitles: false),
// ),
// ),
// gridData: FlGridData(
// show: true,
// // checkToShowHorizontalLine: (value) => value % 10 == 0,
// checkToShowHorizontalLine: (value) => true,
// getDrawingHorizontalLine: (value) => flLine,
// checkToShowVerticalLine: (value) => false,
// getDrawingVerticalLine: (value) => flLine,
// ),
// borderData: FlBorderData(
// show: false,
// ),
// groupsSpace: barSpace,
// barGroups: barChartGroupData(vocabHeadwords),
// backgroundColor: Colors.transparent,
// ),
// swapAnimationDuration: const Duration(milliseconds: 250),
// );
// }
List<BarChartGroupData> barChartGroupData(VocabHeadwords vocabHeadwords) {
// sort vocab into lists
// calculate levels based on vocab data
// List<BarChartGroupData> barChartGroupData(VocabHeadwords vocabHeadwords) {
// // sort vocab into lists
// // calculate levels based on vocab data
final List<BarChartGroupData> chartData = [];
// final List<BarChartGroupData> chartData = [];
vocabHeadwords.lists.asMap().forEach((index, intervalGroup) {
chartData.add(
BarChartGroupData(
x: index,
barsSpace: barSpace,
// barRods: intervalGroup.map(constructBarChartRodData).toList(),
barRods: constructBarChartRodData(intervalGroup),
),
);
});
return chartData;
}
// vocabHeadwords.lists.asMap().forEach((index, intervalGroup) {
// chartData.add(
// BarChartGroupData(
// x: index,
// barsSpace: barSpace,
// // barRods: intervalGroup.map(constructBarChartRodData).toList(),
// barRods: constructBarChartRodData(intervalGroup),
// ),
// );
// });
// return chartData;
// }
List<BarChartRodData> constructBarChartRodData(VocabList vocabList) {
final ListTotals listTotals = vocabList.calculuateTotals();
final y1 = listTotals.low;
final y2 = y1 + listTotals.medium;
final y3 = y2 + listTotals.high;
// List<BarChartRodData> constructBarChartRodData(VocabList vocabList) {
// final ListTotals listTotals = vocabList.calculuateTotals();
// final y1 = listTotals.low;
// final y2 = y1 + listTotals.medium;
// final y3 = y2 + listTotals.high;
return [
BarChartRodData(
toY: y3.toDouble(),
width: 10.toDouble(),
rodStackItems: [
BarChartRodStackItem(0, y1.toDouble(), Colors.red),
BarChartRodStackItem(y1.toDouble(), y2.toDouble(), Colors.grey),
BarChartRodStackItem(y2.toDouble(), y3.toDouble(), Colors.green),
],
borderRadius: BorderRadius.zero,
),
];
}
}
// return [
// BarChartRodData(
// toY: y3.toDouble(),
// width: 10.toDouble(),
// rodStackItems: [
// BarChartRodStackItem(0, y1.toDouble(), Colors.red),
// BarChartRodStackItem(y1.toDouble(), y2.toDouble(), Colors.grey),
// BarChartRodStackItem(y2.toDouble(), y3.toDouble(), Colors.green),
// ],
// borderRadius: BorderRadius.zero,
// ),
// ];
// }
// }

View file

@ -2,6 +2,7 @@ name: fluffychat
# #Pangea
# description: Chat with your friends.
description: Learn a language while texting your friends.
# !!!!!! flutter version: 3.16.9 !!!!!!
# Pangea#
publish_to: none
version: 1.11.2+3453