some questions, name changes, and a couple switches from grammar to morph uses
parent
8d86c06456
commit
c297dea437
@ -1,535 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
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/analytics/constructs_event.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';
|
||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||
|
||||
import '../constants/class_default_values.dart';
|
||||
import '../extensions/client_extension/client_extension.dart';
|
||||
import '../extensions/pangea_room_extension/pangea_room_extension.dart';
|
||||
import 'base_controller.dart';
|
||||
import 'pangea_controller.dart';
|
||||
|
||||
// controls the fetching of analytics data
|
||||
class AnalyticsController extends BaseController {
|
||||
late PangeaController _pangeaController;
|
||||
final List<ConstructCacheEntry> _cachedConstructs = [];
|
||||
|
||||
AnalyticsController(PangeaController pangeaController) : super() {
|
||||
_pangeaController = pangeaController;
|
||||
}
|
||||
|
||||
String get langCode =>
|
||||
_pangeaController.languageController.userL2?.langCode ??
|
||||
_pangeaController.pLanguageStore.targetOptions.first.langCode;
|
||||
|
||||
// String get _analyticsTimeSpanKey => "ANALYTICS_TIME_SPAN_KEY";
|
||||
|
||||
// TimeSpan get currentAnalyticsTimeSpan {
|
||||
// try {
|
||||
// final String? str = _pangeaController.pStoreService.read(
|
||||
// _analyticsTimeSpanKey,
|
||||
// );
|
||||
// return str != null
|
||||
// ? TimeSpan.values.firstWhere((e) {
|
||||
// final spanString = e.toString();
|
||||
// return spanString == str;
|
||||
// })
|
||||
// : ClassDefaultValues.defaultTimeSpan;
|
||||
// } catch (err) {
|
||||
// debugger(when: kDebugMode);
|
||||
// return ClassDefaultValues.defaultTimeSpan;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Future<void> setCurrentAnalyticsTimeSpan(TimeSpan timeSpan) async {
|
||||
// await _pangeaController.pStoreService.save(
|
||||
// _analyticsTimeSpanKey,
|
||||
// timeSpan.toString(),
|
||||
// );
|
||||
// setState();
|
||||
// }
|
||||
|
||||
// String get _analyticsSpaceLangKey => "ANALYTICS_SPACE_LANG_KEY";
|
||||
|
||||
// LanguageModel get currentAnalyticsLang {
|
||||
// try {
|
||||
// final String? str = _pangeaController.pStoreService.read(
|
||||
// _analyticsSpaceLangKey,
|
||||
// );
|
||||
// return str != null
|
||||
// ? PangeaLanguage.byLangCode(str)
|
||||
// : _pangeaController.languageController.userL2 ??
|
||||
// _pangeaController.pLanguageStore.targetOptions.first;
|
||||
// } catch (err) {
|
||||
// debugger(when: kDebugMode);
|
||||
// return _pangeaController.pLanguageStore.targetOptions.first;
|
||||
// }
|
||||
// }
|
||||
|
||||
// Future<void> setCurrentAnalyticsLang(LanguageModel lang) async {
|
||||
// await _pangeaController.pStoreService.save(
|
||||
// _analyticsSpaceLangKey,
|
||||
// lang.langCode,
|
||||
// );
|
||||
// setState();
|
||||
// }
|
||||
|
||||
/// Get the last time the user updated their analytics.
|
||||
/// Tries to get the last time the user updated analytics for their current L2.
|
||||
/// If there isn't yet an analytics room reacted for their L2, checks if the
|
||||
/// user has any other analytics rooms and returns the most recent update time.
|
||||
Future<DateTime?> myAnalyticsLastUpdated() async {
|
||||
final List<Room> analyticsRooms =
|
||||
_pangeaController.matrixState.client.allMyAnalyticsRooms;
|
||||
|
||||
final Map<String, DateTime> langCodeLastUpdates = {};
|
||||
for (final Room analyticsRoom in analyticsRooms) {
|
||||
final String? roomLang = analyticsRoom.madeForLang;
|
||||
if (roomLang == null) continue;
|
||||
final DateTime? lastUpdated = await analyticsRoom.analyticsLastUpdated(
|
||||
_pangeaController.matrixState.client.userID!,
|
||||
);
|
||||
if (lastUpdated != null) {
|
||||
langCodeLastUpdates[roomLang] = lastUpdated;
|
||||
}
|
||||
}
|
||||
|
||||
if (langCodeLastUpdates.isEmpty) return null;
|
||||
final String? l2Code =
|
||||
_pangeaController.languageController.userL2?.langCode;
|
||||
if (l2Code != null && langCodeLastUpdates.containsKey(l2Code)) {
|
||||
return langCodeLastUpdates[l2Code];
|
||||
}
|
||||
return langCodeLastUpdates.values.reduce(
|
||||
(check, mostRecent) => check.isAfter(mostRecent) ? check : mostRecent,
|
||||
);
|
||||
}
|
||||
|
||||
/// check if any students have recently updated their analytics
|
||||
/// if any have, then the cache needs to be updated
|
||||
Future<DateTime?> spaceAnalyticsLastUpdated(
|
||||
Room space,
|
||||
) async {
|
||||
await space.requestParticipants();
|
||||
|
||||
final List<Future<DateTime?>> lastUpdatedFutures = [];
|
||||
for (final student in space.students) {
|
||||
final Room? analyticsRoom = _pangeaController.matrixState.client
|
||||
.analyticsRoomLocal(langCode, student.id);
|
||||
if (analyticsRoom == null) continue;
|
||||
lastUpdatedFutures.add(
|
||||
analyticsRoom.analyticsLastUpdated(student.id),
|
||||
);
|
||||
}
|
||||
|
||||
final List<DateTime?> lastUpdatedWithNulls =
|
||||
await Future.wait(lastUpdatedFutures);
|
||||
final List<DateTime> lastUpdates =
|
||||
lastUpdatedWithNulls.where((e) => e != null).cast<DateTime>().toList();
|
||||
if (lastUpdates.isNotEmpty) {
|
||||
return lastUpdates.reduce(
|
||||
(check, mostRecent) => check.isAfter(mostRecent) ? check : mostRecent,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<List<ConstructAnalyticsEvent>> allMyConstructs(
|
||||
TimeSpan timeSpan,
|
||||
) async {
|
||||
final Room? analyticsRoom =
|
||||
_pangeaController.matrixState.client.analyticsRoomLocal(langCode);
|
||||
if (analyticsRoom == null) return [];
|
||||
|
||||
final List<ConstructAnalyticsEvent>? roomEvents =
|
||||
(await analyticsRoom.getAnalyticsEvents(
|
||||
since: timeSpan.cutOffDate,
|
||||
userId: _pangeaController.matrixState.client.userID!,
|
||||
))
|
||||
?.cast<ConstructAnalyticsEvent>();
|
||||
final List<ConstructAnalyticsEvent> allConstructs = roomEvents ?? [];
|
||||
|
||||
return allConstructs
|
||||
.where((construct) => construct.content.uses.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<List<ConstructAnalyticsEvent>> allSpaceMemberConstructs(
|
||||
Room space,
|
||||
TimeSpan timeSpan,
|
||||
) async {
|
||||
await space.requestParticipants();
|
||||
final List<ConstructAnalyticsEvent> constructEvents = [];
|
||||
for (final student in space.students) {
|
||||
final Room? analyticsRoom = _pangeaController.matrixState.client
|
||||
.analyticsRoomLocal(langCode, student.id);
|
||||
if (analyticsRoom != null) {
|
||||
final List<ConstructAnalyticsEvent>? roomEvents =
|
||||
(await analyticsRoom.getAnalyticsEvents(
|
||||
since: timeSpan.cutOffDate,
|
||||
userId: student.id,
|
||||
))
|
||||
?.cast<ConstructAnalyticsEvent>();
|
||||
constructEvents.addAll(roomEvents ?? []);
|
||||
}
|
||||
}
|
||||
|
||||
final List<String> spaceChildrenIds = space.allSpaceChildRoomIds;
|
||||
final List<ConstructAnalyticsEvent> allConstructs = [];
|
||||
for (final constructEvent in constructEvents) {
|
||||
constructEvent.content.uses.removeWhere(
|
||||
(use) => !spaceChildrenIds.contains(use.chatId),
|
||||
);
|
||||
|
||||
if (constructEvent.content.uses.isNotEmpty) {
|
||||
allConstructs.add(constructEvent);
|
||||
}
|
||||
}
|
||||
|
||||
return allConstructs;
|
||||
}
|
||||
|
||||
List<ConstructAnalyticsEvent> filterStudentConstructs(
|
||||
List<ConstructAnalyticsEvent> unfilteredConstructs,
|
||||
String? studentId,
|
||||
) {
|
||||
final List<ConstructAnalyticsEvent> filtered =
|
||||
List<ConstructAnalyticsEvent>.from(unfilteredConstructs);
|
||||
filtered.removeWhere((element) => element.event.senderId != studentId);
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<ConstructAnalyticsEvent> filterRoomConstructs(
|
||||
List<ConstructAnalyticsEvent> unfilteredConstructs,
|
||||
String? roomID,
|
||||
) {
|
||||
final List<ConstructAnalyticsEvent> filtered = [...unfilteredConstructs];
|
||||
for (final construct in filtered) {
|
||||
construct.content.uses.removeWhere((u) => u.chatId != roomID);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
Future<List<ConstructAnalyticsEvent>> filterPrivateChatConstructs(
|
||||
List<ConstructAnalyticsEvent> unfilteredConstructs,
|
||||
Room space,
|
||||
) async {
|
||||
final List<String> privateChatIds = space.allSpaceChildRoomIds;
|
||||
final resp = await space.client.getSpaceHierarchy(space.id);
|
||||
final List<String> chatIds = resp.rooms.map((room) => room.roomId).toList();
|
||||
for (final id in chatIds) {
|
||||
privateChatIds.removeWhere((e) => e == id);
|
||||
}
|
||||
final List<ConstructAnalyticsEvent> filtered =
|
||||
List<ConstructAnalyticsEvent>.from(unfilteredConstructs);
|
||||
for (final construct in filtered) {
|
||||
construct.content.uses.removeWhere(
|
||||
(use) => !privateChatIds.contains(use.chatId),
|
||||
);
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
|
||||
Future<List<ConstructAnalyticsEvent>> filterSpaceConstructs(
|
||||
List<ConstructAnalyticsEvent> unfilteredConstructs,
|
||||
Room space,
|
||||
) async {
|
||||
final resp = await space.client.getSpaceHierarchy(space.id);
|
||||
final List<String> chatIds = resp.rooms.map((room) => room.roomId).toList();
|
||||
final List<ConstructAnalyticsEvent> filtered =
|
||||
List<ConstructAnalyticsEvent>.from(unfilteredConstructs);
|
||||
|
||||
for (final construct in filtered) {
|
||||
construct.content.uses.removeWhere(
|
||||
(use) => !chatIds.contains(use.chatId),
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
List<ConstructAnalyticsEvent>? getConstructsLocal({
|
||||
required TimeSpan timeSpan,
|
||||
required AnalyticsSelected defaultSelected,
|
||||
AnalyticsSelected? selected,
|
||||
DateTime? lastUpdated,
|
||||
ConstructTypeEnum? constructType,
|
||||
}) {
|
||||
final index = _cachedConstructs.indexWhere(
|
||||
(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 &&
|
||||
e.langCode == langCode,
|
||||
);
|
||||
|
||||
if (index > -1) {
|
||||
if (_cachedConstructs[index].needsUpdate(lastUpdated)) {
|
||||
_cachedConstructs.removeAt(index);
|
||||
return null;
|
||||
}
|
||||
return _cachedConstructs[index].events;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
void cacheConstructs({
|
||||
required List<ConstructAnalyticsEvent> events,
|
||||
required AnalyticsSelected defaultSelected,
|
||||
required TimeSpan timeSpan,
|
||||
AnalyticsSelected? selected,
|
||||
ConstructTypeEnum? constructType,
|
||||
}) {
|
||||
final entry = ConstructCacheEntry(
|
||||
timeSpan: timeSpan,
|
||||
type: constructType,
|
||||
events: List.from(events),
|
||||
defaultSelected: defaultSelected,
|
||||
selected: selected,
|
||||
langCode: langCode,
|
||||
);
|
||||
_cachedConstructs.add(entry);
|
||||
}
|
||||
|
||||
Future<List<ConstructAnalyticsEvent>> getMyConstructs({
|
||||
required AnalyticsSelected defaultSelected,
|
||||
required TimeSpan timeSpan,
|
||||
ConstructTypeEnum? constructType,
|
||||
AnalyticsSelected? selected,
|
||||
}) async {
|
||||
final List<ConstructAnalyticsEvent> unfilteredConstructs =
|
||||
await allMyConstructs(timeSpan);
|
||||
|
||||
final Room? space = selected?.type == AnalyticsEntryType.space
|
||||
? _pangeaController.matrixState.client.getRoomById(selected!.id)
|
||||
: null;
|
||||
|
||||
return filterConstructs(
|
||||
unfilteredConstructs: unfilteredConstructs,
|
||||
space: space,
|
||||
defaultSelected: defaultSelected,
|
||||
selected: selected,
|
||||
timeSpan: timeSpan,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ConstructAnalyticsEvent>> getSpaceConstructs({
|
||||
required Room space,
|
||||
required AnalyticsSelected defaultSelected,
|
||||
required TimeSpan timeSpan,
|
||||
AnalyticsSelected? selected,
|
||||
ConstructTypeEnum? constructType,
|
||||
}) async {
|
||||
final List<ConstructAnalyticsEvent> unfilteredConstructs =
|
||||
await allSpaceMemberConstructs(
|
||||
space,
|
||||
timeSpan,
|
||||
);
|
||||
|
||||
return filterConstructs(
|
||||
unfilteredConstructs: unfilteredConstructs,
|
||||
space: space,
|
||||
defaultSelected: defaultSelected,
|
||||
selected: selected,
|
||||
timeSpan: timeSpan,
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<ConstructAnalyticsEvent>> filterConstructs({
|
||||
required List<ConstructAnalyticsEvent> unfilteredConstructs,
|
||||
required AnalyticsSelected defaultSelected,
|
||||
required TimeSpan timeSpan,
|
||||
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];
|
||||
construct.content.uses.removeWhere(
|
||||
(use) => use.timeStamp.isBefore(timeSpan.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}",
|
||||
);
|
||||
}
|
||||
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"
|
||||
: await filterPrivateChatConstructs(unfilteredConstructs, space!);
|
||||
case AnalyticsEntryType.space:
|
||||
return await filterSpaceConstructs(unfilteredConstructs, space!);
|
||||
default:
|
||||
throw Exception("invalid filter type - ${selected?.type}");
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<ConstructAnalyticsEvent>?> getConstructs({
|
||||
required AnalyticsSelected defaultSelected,
|
||||
required TimeSpan timeSpan,
|
||||
AnalyticsSelected? selected,
|
||||
bool removeIT = true,
|
||||
bool forceUpdate = false,
|
||||
ConstructTypeEnum? constructType,
|
||||
}) async {
|
||||
debugPrint("getting constructs");
|
||||
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 setConstructs",
|
||||
data: {
|
||||
"defaultSelected": defaultSelected,
|
||||
"selected": selected,
|
||||
},
|
||||
);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? lastUpdated;
|
||||
if (defaultSelected.type != AnalyticsEntryType.space) {
|
||||
// if default selected view is my analytics, check for the last
|
||||
// time the logged in user updated their analytics events
|
||||
// this gets passed to getAnalyticsLocal to determine if the cached
|
||||
// entry is out-of-date
|
||||
lastUpdated = await myAnalyticsLastUpdated();
|
||||
} else {
|
||||
// else, get the last time a student in the space updated their analytics
|
||||
lastUpdated = await spaceAnalyticsLastUpdated(
|
||||
space!,
|
||||
);
|
||||
}
|
||||
|
||||
final List<ConstructAnalyticsEvent>? local = getConstructsLocal(
|
||||
timeSpan: timeSpan,
|
||||
constructType: constructType,
|
||||
defaultSelected: defaultSelected,
|
||||
selected: selected,
|
||||
lastUpdated: lastUpdated,
|
||||
);
|
||||
if (local != null && !forceUpdate) {
|
||||
debugPrint("returning local constructs");
|
||||
return local;
|
||||
}
|
||||
debugPrint("fetching new constructs");
|
||||
|
||||
final filteredConstructs = space == null
|
||||
? await getMyConstructs(
|
||||
constructType: constructType,
|
||||
defaultSelected: defaultSelected,
|
||||
selected: selected,
|
||||
timeSpan: timeSpan,
|
||||
)
|
||||
: await getSpaceConstructs(
|
||||
constructType: constructType,
|
||||
space: space,
|
||||
defaultSelected: defaultSelected,
|
||||
selected: selected,
|
||||
timeSpan: timeSpan,
|
||||
);
|
||||
|
||||
if (removeIT) {
|
||||
for (final construct in filteredConstructs) {
|
||||
construct.content.uses.removeWhere(
|
||||
(element) =>
|
||||
element.lemma == "Try interactive translation" ||
|
||||
element.lemma == "itStart" ||
|
||||
element.lemma == MatchRuleIds.interactiveTranslation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (local == null) {
|
||||
cacheConstructs(
|
||||
constructType: constructType,
|
||||
events: filteredConstructs,
|
||||
defaultSelected: defaultSelected,
|
||||
selected: selected,
|
||||
timeSpan: timeSpan,
|
||||
);
|
||||
}
|
||||
|
||||
return filteredConstructs;
|
||||
}
|
||||
}
|
||||
|
||||
abstract class CacheEntry {
|
||||
final String langCode;
|
||||
final TimeSpan timeSpan;
|
||||
final AnalyticsSelected defaultSelected;
|
||||
AnalyticsSelected? selected;
|
||||
late final DateTime _createdAt;
|
||||
|
||||
CacheEntry({
|
||||
required this.timeSpan,
|
||||
required this.defaultSelected,
|
||||
required this.langCode,
|
||||
this.selected,
|
||||
}) {
|
||||
_createdAt = DateTime.now();
|
||||
}
|
||||
|
||||
bool get isExpired =>
|
||||
DateTime.now().difference(_createdAt).inMinutes >
|
||||
ClassDefaultValues.minutesDelayToMakeNewChartAnalytics;
|
||||
|
||||
bool needsUpdate(DateTime? lastEventUpdated) {
|
||||
// cache entry is invalid if it's older than the last event update
|
||||
// if lastEventUpdated is null, that would indicate that no events
|
||||
// of this type have been sent to the room. In this case, there
|
||||
// shouldn't be any cached data.
|
||||
if (lastEventUpdated == null) {
|
||||
Sentry.addBreadcrumb(
|
||||
Breadcrumb(message: "lastEventUpdated is null in needsUpdate"),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return _createdAt.isBefore(lastEventUpdated);
|
||||
}
|
||||
}
|
||||
|
||||
class ConstructCacheEntry extends CacheEntry {
|
||||
final ConstructTypeEnum? type;
|
||||
final List<ConstructAnalyticsEvent> events;
|
||||
|
||||
ConstructCacheEntry({
|
||||
required this.events,
|
||||
required super.timeSpan,
|
||||
required super.langCode,
|
||||
required super.defaultSelected,
|
||||
this.type,
|
||||
super.selected,
|
||||
});
|
||||
}
|
||||
@ -1,572 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
|
||||
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/enum/time_span.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_representation_event.dart';
|
||||
import 'package:fluffychat/pangea/models/analytics/construct_list_model.dart';
|
||||
import 'package:fluffychat/pangea/models/analytics/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/models/pangea_match_model.dart';
|
||||
import 'package:fluffychat/pangea/pages/analytics/base_analytics.dart';
|
||||
import 'package:fluffychat/pangea/utils/error_handler.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';
|
||||
|
||||
class ConstructList extends StatefulWidget {
|
||||
final ConstructTypeEnum constructType;
|
||||
final AnalyticsSelected defaultSelected;
|
||||
final AnalyticsSelected? selected;
|
||||
final TimeSpan timeSpan;
|
||||
final PangeaController pangeaController;
|
||||
final StreamController refreshStream;
|
||||
|
||||
const ConstructList({
|
||||
super.key,
|
||||
required this.constructType,
|
||||
required this.defaultSelected,
|
||||
required this.pangeaController,
|
||||
required this.refreshStream,
|
||||
required this.timeSpan,
|
||||
this.selected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => ConstructListState();
|
||||
}
|
||||
|
||||
class ConstructListState extends State<ConstructList> {
|
||||
String? langCode;
|
||||
String? error;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return error != null
|
||||
? Center(
|
||||
child: Text(error!),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
ConstructListView(
|
||||
pangeaController: widget.pangeaController,
|
||||
defaultSelected: widget.defaultSelected,
|
||||
selected: widget.selected,
|
||||
refreshStream: widget.refreshStream,
|
||||
timeSpan: widget.timeSpan,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// list view of construct events
|
||||
// parameters
|
||||
// 1) a list of construct events and
|
||||
// 2) a boolean indicating whether the list has been initialized
|
||||
// if not initialized, show loading indicator
|
||||
// for each tile,
|
||||
// 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 StatefulWidget {
|
||||
final PangeaController pangeaController;
|
||||
final AnalyticsSelected defaultSelected;
|
||||
final TimeSpan timeSpan;
|
||||
final AnalyticsSelected? selected;
|
||||
final StreamController refreshStream;
|
||||
|
||||
const ConstructListView({
|
||||
super.key,
|
||||
required this.pangeaController,
|
||||
required this.defaultSelected,
|
||||
required this.timeSpan,
|
||||
required this.refreshStream,
|
||||
this.selected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => ConstructListViewState();
|
||||
}
|
||||
|
||||
class ConstructListViewState extends State<ConstructListView> {
|
||||
final ConstructTypeEnum constructType = ConstructTypeEnum.grammar;
|
||||
final Map<String, Timeline> _timelinesCache = {};
|
||||
final Map<String, PangeaMessageEvent> _msgEventCache = {};
|
||||
final List<PangeaMessageEvent> _msgEvents = [];
|
||||
bool fetchingConstructs = true;
|
||||
bool fetchingUses = false;
|
||||
StreamSubscription? refreshSubscription;
|
||||
String? currentLemma;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.pangeaController.analytics
|
||||
.getConstructs(
|
||||
constructType: constructType,
|
||||
forceUpdate: true,
|
||||
)
|
||||
.whenComplete(() => setState(() => fetchingConstructs = false))
|
||||
.then(
|
||||
(value) => setState(
|
||||
() => constructs = ConstructListModel(
|
||||
type: constructType,
|
||||
uses: value,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
refreshSubscription = widget.refreshStream.stream.listen((forceUpdate) {
|
||||
// postframe callback to let widget rebuild with the new selected parameter
|
||||
// before sending selected to getConstructs function
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
widget.pangeaController.analytics
|
||||
.getConstructs(
|
||||
constructType: constructType,
|
||||
forceUpdate: true,
|
||||
)
|
||||
.then(
|
||||
(value) => setState(() {
|
||||
ConstructListModel(
|
||||
type: constructType,
|
||||
uses: value,
|
||||
);
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
refreshSubscription?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void setCurrentLemma(String? lemma) {
|
||||
currentLemma = lemma;
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
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) {
|
||||
return null;
|
||||
}
|
||||
|
||||
Timeline? timeline;
|
||||
if (_timelinesCache.containsKey(use.chatId)) {
|
||||
timeline = _timelinesCache[use.chatId];
|
||||
} else {
|
||||
timeline = msgRoom.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 (currentLemma == null) {
|
||||
setState(() => _msgEvents.clear());
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => fetchingUses = true);
|
||||
try {
|
||||
final List<OneConstructUse> uses = constructs?.constructList
|
||||
.firstWhereOrNull(
|
||||
(element) => element.lemma == currentLemma,
|
||||
)
|
||||
?.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);
|
||||
} catch (err, s) {
|
||||
setState(() => fetchingUses = false);
|
||||
debugPrint("Error fetching uses: $err");
|
||||
ErrorHandler.logError(
|
||||
e: err,
|
||||
s: s,
|
||||
m: "Failed to fetch uses for current construct $currentLemma",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
ConstructListModel? constructs;
|
||||
|
||||
// given the current lemma and list of message events, return a list of
|
||||
// MessageEventMatch objects, which contain one PangeaMessageEvent to one PangeaMatch
|
||||
// this is because some message events may have has more than one PangeaMatch of a
|
||||
// given lemma type.
|
||||
List<MessageEventMatch> getMessageEventMatches() {
|
||||
if (currentLemma == null) return [];
|
||||
final List<MessageEventMatch> allMsgErrorSteps = [];
|
||||
|
||||
for (final msgEvent in _msgEvents) {
|
||||
if (allMsgErrorSteps.any(
|
||||
(element) => element.msgEvent.eventId == msgEvent.eventId,
|
||||
)) {
|
||||
continue;
|
||||
}
|
||||
// get all the pangea matches in that message which have that lemma
|
||||
final List<PangeaMatch>? msgErrorSteps = msgEvent.errorSteps(
|
||||
currentLemma!,
|
||||
);
|
||||
if (msgErrorSteps == null) continue;
|
||||
|
||||
allMsgErrorSteps.addAll(
|
||||
msgErrorSteps.map(
|
||||
(errorStep) => MessageEventMatch(
|
||||
msgEvent: msgEvent,
|
||||
lemmaMatch: errorStep,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return allMsgErrorSteps;
|
||||
}
|
||||
|
||||
Future<void> showConstructMessagesDialog() async {
|
||||
await showDialog<ConstructMessagesDialog>(
|
||||
context: context,
|
||||
builder: (c) => ConstructMessagesDialog(controller: this),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (fetchingConstructs || fetchingUses) {
|
||||
return const Expanded(
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
if (constructs?.constructList.isEmpty ?? true) {
|
||||
return Expanded(
|
||||
child: Center(child: Text(L10n.of(context)!.noDataFound)),
|
||||
);
|
||||
}
|
||||
|
||||
return Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: constructs!.constructList.length,
|
||||
itemBuilder: (context, index) {
|
||||
return ListTile(
|
||||
title: Text(
|
||||
constructs!.constructList[index].lemma,
|
||||
),
|
||||
subtitle: Text(
|
||||
'${L10n.of(context)!.total} ${constructs!.constructList[index].uses.length}',
|
||||
),
|
||||
onTap: () async {
|
||||
final String lemma = constructs!.constructList[index].lemma;
|
||||
setCurrentLemma(lemma);
|
||||
fetchUses().then((_) => showConstructMessagesDialog());
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConstructMessagesDialog extends StatelessWidget {
|
||||
final ConstructListViewState controller;
|
||||
const ConstructMessagesDialog({
|
||||
super.key,
|
||||
required this.controller,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (controller.currentLemma == null || controller.constructs == null) {
|
||||
return const AlertDialog(content: CircularProgressIndicator.adaptive());
|
||||
}
|
||||
|
||||
final msgEventMatches = controller.getMessageEventMatches();
|
||||
|
||||
final currentConstruct =
|
||||
controller.constructs!.constructList.firstWhereOrNull(
|
||||
(construct) => construct.lemma == controller.currentLemma,
|
||||
);
|
||||
final noData = currentConstruct == null ||
|
||||
currentConstruct.uses.length > controller._msgEvents.length;
|
||||
|
||||
return AlertDialog(
|
||||
title: Center(child: Text(controller.currentLemma!)),
|
||||
content: SizedBox(
|
||||
height: noData ? 90 : 250,
|
||||
width: noData ? 200 : 400,
|
||||
child: Column(
|
||||
children: [
|
||||
if (noData)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(L10n.of(context)!.roomDataMissing),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
...msgEventMatches.mapIndexed(
|
||||
(index, event) => Column(
|
||||
children: [
|
||||
ConstructMessage(
|
||||
msgEvent: event.msgEvent,
|
||||
lemma: controller.currentLemma!,
|
||||
errorMessage: event.lemmaMatch,
|
||||
),
|
||||
if (index < msgEventMatches.length - 1)
|
||||
const Divider(height: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context, rootNavigator: false).pop(),
|
||||
child: Text(
|
||||
L10n.of(context)!.close.toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConstructMessage extends StatelessWidget {
|
||||
final PangeaMessageEvent msgEvent;
|
||||
final PangeaMatch errorMessage;
|
||||
final String lemma;
|
||||
|
||||
const ConstructMessage({
|
||||
super.key,
|
||||
required this.msgEvent,
|
||||
required this.errorMessage,
|
||||
required this.lemma,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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,
|
||||
required this.start,
|
||||
required this.end,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final defaultStyle = TextStyle(
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
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: 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) {
|
||||
final String roomName = msgEvent.event.room.getLocalizedDisplayname();
|
||||
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(roomName),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MessageEventMatch {
|
||||
final PangeaMessageEvent msgEvent;
|
||||
final PangeaMatch lemmaMatch;
|
||||
|
||||
MessageEventMatch({
|
||||
required this.msgEvent,
|
||||
required this.lemmaMatch,
|
||||
});
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
// import 'dart:math';
|
||||
|
||||
// import 'package:fluffychat/pangea/models/analytics/chart_analytics_model.dart';
|
||||
// import 'package:flutter/material.dart';
|
||||
// import 'package:flutter_gen/gen_l10n/l10n.dart';
|
||||
|
||||
// import '../../enum/use_type.dart';
|
||||
|
||||
// class ListSummaryAnalytics extends StatelessWidget {
|
||||
// final ChartAnalyticsModel? chartAnalytics;
|
||||
|
||||
// const ListSummaryAnalytics({super.key, this.chartAnalytics});
|
||||
|
||||
// TimeSeriesTotals? get totals => chartAnalytics?.totals;
|
||||
|
||||
// String spacer(int baseLength, int number) =>
|
||||
// " " * max(baseLength - number.toString().length, 0);
|
||||
|
||||
// WidgetSpan spacerIconText(
|
||||
// String toolTip,
|
||||
// String space,
|
||||
// IconData icon,
|
||||
// int value,
|
||||
// Color? color, [
|
||||
// percentage = true,
|
||||
// ]) =>
|
||||
// WidgetSpan(
|
||||
// child: Tooltip(
|
||||
// message: toolTip,
|
||||
// child: RichText(
|
||||
// text: TextSpan(
|
||||
// children: [
|
||||
// TextSpan(
|
||||
// text: space,
|
||||
// ),
|
||||
// WidgetSpan(child: Icon(icon, size: 14, color: color)),
|
||||
// TextSpan(
|
||||
// text: " $value${percentage ? "%" : ""}",
|
||||
// style: TextStyle(color: color),
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
// );
|
||||
|
||||
// @override
|
||||
// Widget build(BuildContext context) {
|
||||
// if (totals == null) {
|
||||
// return const LinearProgressIndicator();
|
||||
// }
|
||||
// final l10n = L10n.of(context);
|
||||
|
||||
// return RichText(
|
||||
// text: TextSpan(
|
||||
// children: [
|
||||
// spacerIconText(
|
||||
// L10n.of(context) != null
|
||||
// ? L10n.of(context)!.totalMessages
|
||||
// : "Total messages sent",
|
||||
// "",
|
||||
// Icons.chat_bubble,
|
||||
// totals!.all,
|
||||
// Theme.of(context).textTheme.bodyLarge!.color,
|
||||
// false,
|
||||
// ),
|
||||
// if (totals!.all != 0) ...[
|
||||
// spacerIconText(
|
||||
// l10n != null ? l10n.taTooltip : "With translation assistance",
|
||||
// spacer(8, totals!.all),
|
||||
// UseType.ta.iconData,
|
||||
// totals!.taPercent,
|
||||
// UseType.ta.color(context),
|
||||
// ),
|
||||
// spacerIconText(
|
||||
// l10n != null ? l10n.gaTooltip : "With grammar assistance",
|
||||
// spacer(4, totals!.taPercent),
|
||||
// UseType.ga.iconData,
|
||||
// totals!.gaPercent,
|
||||
// UseType.ga.color(context),
|
||||
// ),
|
||||
// spacerIconText(
|
||||
// l10n != null ? l10n.waTooltip : "Without assistance",
|
||||
// spacer(4, totals!.gaPercent),
|
||||
// UseType.wa.iconData,
|
||||
// totals!.waPercent,
|
||||
// UseType.wa.color(context),
|
||||
// ),
|
||||
// spacerIconText(
|
||||
// l10n != null ? l10n.unTooltip : "Other",
|
||||
// spacer(4, totals!.waPercent),
|
||||
// UseType.un.iconData,
|
||||
// totals!.unPercent,
|
||||
// UseType.un.color(context),
|
||||
// ),
|
||||
// ],
|
||||
// ],
|
||||
// ),
|
||||
// );
|
||||
// }
|
||||
// }
|
||||
Loading…
Reference in New Issue