3770 total vocab grammar and xp calculations per user and activity (#3775)

This commit is contained in:
ggurdin 2025-08-19 10:15:22 -04:00 committed by GitHub
parent b4cb8f6edc
commit ece75b7f74
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 689 additions and 458 deletions

View file

@ -973,33 +973,13 @@ class ChatController extends State<ChatPageWithRoom>
// There's a listen in my_analytics_controller that decides when to auto-update
// analytics based on when / how many messages the logged in user send. This
// stream sends the data for newly sent messages.
final metadata = ConstructUseMetaData(
roomId: roomId,
timeStamp: DateTime.now(),
eventId: msgEventId,
_sendMessageAnalytics(
msgEventId,
originalSent: originalSent,
tokensSent: tokensSent,
choreo: choreo,
);
if (msgEventId != null && originalSent != null && tokensSent != null) {
final List<OneConstructUse> constructs = [
...originalSent.vocabAndMorphUses(
choreo: choreo,
tokens: tokensSent.tokens,
metadata: metadata,
),
];
_showAnalyticsFeedback(constructs, msgEventId);
pangeaController.putAnalytics.setState(
AnalyticsStream(
eventId: msgEventId,
targetID: msgEventId,
roomId: room.id,
constructs: constructs,
),
);
}
if (previousEdit != null) {
pangeaEditingEvent = previousEdit;
}
@ -2091,6 +2071,48 @@ class ChatController extends State<ChatPageWithRoom>
}
}
Future<void> _sendMessageAnalytics(
String? eventId, {
PangeaRepresentation? originalSent,
PangeaMessageTokens? tokensSent,
ChoreoRecord? choreo,
}) async {
// There's a listen in my_analytics_controller that decides when to auto-update
// analytics based on when / how many messages the logged in user send. This
// stream sends the data for newly sent messages.
if (originalSent?.langCode.split("-").first !=
choreographer.l2Lang?.langCodeShort) {
return;
}
final metadata = ConstructUseMetaData(
roomId: roomId,
timeStamp: DateTime.now(),
eventId: eventId,
);
if (eventId != null && originalSent != null && tokensSent != null) {
final List<OneConstructUse> constructs = [
...originalSent.vocabAndMorphUses(
choreo: choreo,
tokens: tokensSent.tokens,
metadata: metadata,
),
];
_showAnalyticsFeedback(constructs, eventId);
pangeaController.putAnalytics.setState(
AnalyticsStream(
eventId: eventId,
targetID: eventId,
roomId: room.id,
constructs: constructs,
),
);
}
}
Future<void> _sendVoiceMessageAnalytics(String? eventId) async {
if (eventId == null) {
ErrorHandler.logError(

View file

@ -12,9 +12,9 @@ import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/settings/settings.dart';
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
import 'package:fluffychat/pangea/chat_settings/pages/pangea_chat_details.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/download/download_room_extension.dart';
import 'package:fluffychat/pangea/download/download_type_enum.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/utils/file_selector.dart';
@ -238,7 +238,26 @@ class ChatDetailsController extends State<ChatDetails> {
],
);
if (type == null) return;
downloadChat(room, type, context);
try {
await room.download(type, context);
} on EmptyChatException {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).emptyChatDownloadWarning,
),
),
);
} catch (e) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"${L10n.of(context).oopsSomethingWentWrong} ${L10n.of(context).errorPleaseRefresh}",
),
),
);
}
}
Future<void> setBotOptions(BotOptionsModel botOptions) async {

View file

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
class ActivityAnalyticsChip extends StatelessWidget {
final IconData icon;
final String text;
const ActivityAnalyticsChip(
this.icon,
this.text, {
super.key,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
),
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 12.0),
Text(
text,
style: const TextStyle(
fontSize: 12.0,
),
),
],
),
);
}
}

View file

@ -7,10 +7,13 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_analytics_chip.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_participant_indicator.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_results_carousel.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_room_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -103,6 +106,21 @@ class ActivityFinishedStatusMessage extends StatelessWidget {
),
),
),
if (summary.analytics != null)
Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
ActivityAnalyticsChip(
ConstructTypeEnum.vocab.indicator.icon,
"${summary.analytics!.uniqueConstructCount(ConstructTypeEnum.vocab)}",
),
ActivityAnalyticsChip(
ConstructTypeEnum.morph.indicator.icon,
"${summary.analytics!.uniqueConstructCount(ConstructTypeEnum.morph)}",
),
],
),
const SizedBox(height: 16.0),
if (_highlightedRole != null && userSummary != null)
ClipRRect(
@ -114,9 +132,11 @@ class ActivityFinishedStatusMessage extends StatelessWidget {
child: Column(
children: [
ActivityResultsCarousel(
userId: _highlightedRole!.userId,
selectedRole: _highlightedRole!,
user: user,
summary: userSummary,
analytics: summary.analytics,
),
Wrap(
alignment: WrapAlignment.center,

View file

@ -3,19 +3,27 @@ import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_analytics_chip.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
class ActivityResultsCarousel extends StatelessWidget {
final String userId;
final ActivityRoleModel selectedRole;
final ParticipantSummaryModel summary;
final ActivitySummaryAnalyticsModel? analytics;
final User? user;
const ActivityResultsCarousel({
super.key,
required this.userId,
required this.selectedRole,
required this.summary,
required this.analytics,
this.user,
});
@ -48,25 +56,19 @@ class ActivityResultsCarousel extends StatelessWidget {
spacing: 8.0,
runSpacing: 8.0,
children: [
Container(
padding: const EdgeInsets.all(4.0),
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(20),
if (analytics != null)
ActivityAnalyticsChip(
ConstructTypeEnum.vocab.indicator.icon,
"${analytics!.uniqueConstructCountForUser(userId, ConstructTypeEnum.vocab)}",
),
child: Row(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.school, size: 12.0),
Text(
summary.cefrLevel,
style: const TextStyle(
fontSize: 12.0,
),
),
],
if (analytics != null)
ActivityAnalyticsChip(
ConstructTypeEnum.morph.indicator.icon,
"${analytics!.uniqueConstructCountForUser(userId, ConstructTypeEnum.morph)}",
),
ActivityAnalyticsChip(
Icons.school,
summary.cefrLevel,
),
...summary.superlatives.map(
(sup) => Container(

View file

@ -8,15 +8,16 @@ import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_role_model.dart';
import 'package:fluffychat/pangea/activity_sessions/activity_roles_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_repo.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_request_model.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart';
import 'package:fluffychat/pangea/common/config/environment.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import '../activity_summary/activity_summary_repo.dart';
extension ActivityRoomExtension on Room {
Future<void> sendActivityPlan(
@ -119,7 +120,9 @@ extension ActivityRoomExtension on Room {
}
Future<void> fetchSummaries() async {
if (activitySummary?.summary != null) return;
if (activitySummary?.summary != null) {
return;
}
await setActivitySummary(
ActivitySummaryModel(
@ -128,15 +131,18 @@ extension ActivityRoomExtension on Room {
),
);
final events = await getAllEvents(this);
final events = await getAllEvents();
final List<ActivitySummaryResultsMessage> messages = [];
final ActivitySummaryAnalyticsModel analytics =
ActivitySummaryAnalyticsModel();
final timeline = this.timeline ?? await getTimeline();
for (final event in events) {
if (event.type != EventTypes.Message ||
event.messageType != MessageTypes.Text) {
continue;
}
final timeline = this.timeline ?? await getTimeline();
final pangeaMessage = PangeaMessageEvent(
event: event,
timeline: timeline,
@ -155,6 +161,7 @@ extension ActivityRoomExtension on Room {
);
messages.add(activityMessage);
analytics.addConstructs(pangeaMessage);
}
try {
@ -163,11 +170,15 @@ extension ActivityRoomExtension on Room {
activity: activityPlan!,
activityResults: messages,
contentFeedback: [],
analytics: analytics,
),
);
await setActivitySummary(
ActivitySummaryModel(summary: resp),
ActivitySummaryModel(
summary: resp,
analytics: analytics,
),
);
} catch (e, s) {
ErrorHandler.logError(

View file

@ -0,0 +1,110 @@
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
class ActivitySummaryAnalyticsModel {
final Map<String, UserConstructAnalytics> constructs = {};
ActivitySummaryAnalyticsModel();
Map<ConstructTypeEnum, int> uniqueConstructCountsByType() {
final Map<ConstructTypeEnum, Set<ConstructIdentifier>> typeToIds = {};
for (final userAnalytics in constructs.values) {
for (final usage in userAnalytics.usages.values) {
final id = usage.identifier;
typeToIds.putIfAbsent(id.type, () => <ConstructIdentifier>{}).add(id);
}
}
return {
for (final entry in typeToIds.entries) entry.key: entry.value.length,
};
}
int uniqueConstructCount(ConstructTypeEnum type) =>
uniqueConstructCountsByType()[type] ?? 0;
/// Unique constructs of a given type for a specific user
int uniqueConstructCountForUser(String userId, ConstructTypeEnum type) {
final userAnalytics = constructs[userId];
if (userAnalytics == null) return 0;
return userAnalytics.constructsOfType(type).length;
}
void addConstructs(PangeaMessageEvent event) {
final uses = event.originalSent?.vocabAndMorphUses();
if (uses == null || uses.isEmpty) return;
final user =
constructs[event.senderId] ??= UserConstructAnalytics(event.senderId);
for (final use in uses) {
user.addUsage(use.identifier);
}
}
factory ActivitySummaryAnalyticsModel.fromJson(Map<String, dynamic> json) {
final model = ActivitySummaryAnalyticsModel();
for (final userEntry in json.entries) {
final userId = userEntry.key;
final constructList = userEntry.value as List<dynamic>;
final userAnalytics = UserConstructAnalytics(userId);
for (final constructJson in constructList) {
final constructId = ConstructIdentifier.fromJson(constructJson);
final timesUsed = constructJson['times_used'] as int? ?? 0;
final usage = ConstructUsage(constructId)..timesUsed = timesUsed;
userAnalytics.usages[constructId.string] = usage;
}
model.constructs[userId] = userAnalytics;
}
return model;
}
Map<String, dynamic> toJson() => {
for (final entry in constructs.entries)
entry.key: entry.value.toJsonList(),
};
}
class ConstructUsage {
final ConstructIdentifier identifier;
int timesUsed;
ConstructUsage(this.identifier) : timesUsed = 0;
void increment() => timesUsed++;
Map<String, dynamic> toJson() => {
...identifier.toJson(),
'times_used': timesUsed,
};
}
class UserConstructAnalytics {
final String userId;
final Map<String, ConstructUsage> usages;
UserConstructAnalytics(this.userId) : usages = {};
/// Unique constructs of a given type
Set<ConstructIdentifier> constructsOfType(ConstructTypeEnum type) =>
usages.values
.map((u) => u.identifier)
.where((id) => id.type == type)
.toSet();
void addUsage(ConstructIdentifier id) {
usages[id.string] ??= ConstructUsage(id);
usages[id.string]!.increment();
}
List<Map<String, dynamic>> toJsonList() =>
usages.values.map((u) => u.toJson()).toList();
}

View file

@ -1,14 +1,17 @@
import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart';
class ActivitySummaryModel {
final ActivitySummaryResponseModel? summary;
final DateTime? requestedAt;
final DateTime? errorAt;
final ActivitySummaryAnalyticsModel? analytics;
ActivitySummaryModel({
this.summary,
this.requestedAt,
this.errorAt,
this.analytics,
});
Map<String, dynamic> toJson() {
@ -16,6 +19,7 @@ class ActivitySummaryModel {
"summary": summary?.toJson(),
"requested_at": requestedAt?.toIso8601String(),
"error_at": errorAt?.toIso8601String(),
"analytics": analytics?.toJson(),
};
}
@ -29,6 +33,9 @@ class ActivitySummaryModel {
: null,
errorAt:
json['error_at'] != null ? DateTime.parse(json['error_at']) : null,
analytics: json['analytics'] != null
? ActivitySummaryAnalyticsModel.fromJson(json['analytics'])
: null,
);
}

View file

@ -1,6 +1,7 @@
// Add this import for the participant summary model
import 'package:fluffychat/pangea/activity_planner/activity_plan_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_analytics_model.dart';
import 'package:fluffychat/pangea/activity_summary/activity_summary_response_model.dart';
class ActivitySummaryResultsMessage {
@ -67,11 +68,13 @@ class ActivitySummaryRequestModel {
final ActivityPlanModel activity;
final List<ActivitySummaryResultsMessage> activityResults;
final List<ContentFeedbackModel> contentFeedback;
final ActivitySummaryAnalyticsModel analytics;
ActivitySummaryRequestModel({
required this.activity,
required this.activityResults,
required this.contentFeedback,
required this.analytics,
});
Map<String, dynamic> toJson() {
@ -79,6 +82,7 @@ class ActivitySummaryRequestModel {
'activity': activity.toJson(),
'activity_results': activityResults.map((e) => e.toJson()).toList(),
'content_feedback': contentFeedback.map((e) => e.toJson()).toList(),
'analytics': analytics.toJson(),
};
}
}

View file

@ -15,10 +15,11 @@ import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/constructs/construct_identifier.dart';
import 'package:fluffychat/pangea/download/download_file_util.dart';
import 'package:fluffychat/pangea/download/download_type_enum.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/morphs/morph_features_enum.dart';
@ -101,12 +102,12 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
"analytics_morph_${MatrixState.pangeaController.matrixState.client.userID?.localpart}_${DateFormat('yyyy-MM-dd-hh:mm:ss').format(DateTime.now())}.csv";
final futures = [
downloadFile(
DownloadUtil.downloadFile(
vocabContent,
vocabFileName,
_downloadType,
),
downloadFile(
DownloadUtil.downloadFile(
morphContent,
morphFileName,
_downloadType,
@ -123,7 +124,7 @@ class AnalyticsDownloadDialogState extends State<AnalyticsDownloadDialog> {
final fileName =
"analytics_${MatrixState.pangeaController.matrixState.client.userID?.localpart}_${DateFormat('yyyy-MM-dd-hh:mm:ss').format(DateTime.now())}.xlsx'}";
await downloadFile(
await DownloadUtil.downloadFile(
content,
fileName,
_downloadType,

View file

@ -1,338 +0,0 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:csv/csv.dart';
import 'package:excel/excel.dart';
import 'package:intl/intl.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/models/timeline_chunk.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
Future<void> downloadChat(
Room room,
DownloadType type,
BuildContext context,
) async {
List<PangeaMessageEvent> allPangeaMessages;
try {
final List<Event> allEvents = await getAllEvents(room);
final TimelineChunk chunk = TimelineChunk(events: allEvents);
final Timeline timeline = Timeline(
room: room,
chunk: chunk,
);
allPangeaMessages = getPangeaMessageEvents(
allEvents,
timeline,
room,
);
} catch (err, s) {
ErrorHandler.logError(
e: err,
s: s,
data: {
"roomID": room.id,
},
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
"${L10n.of(context).oopsSomethingWentWrong} ${L10n.of(context).errorPleaseRefresh}",
),
),
);
return;
}
if (allPangeaMessages.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
L10n.of(context).emptyChatDownloadWarning,
),
),
);
return;
}
final String filename = getFilename(room, type);
switch (type) {
case DownloadType.txt:
final String content =
getTxtContent(allPangeaMessages, context, filename, room);
downloadFile(content, filename, DownloadType.txt);
break;
case DownloadType.csv:
final String content =
getCSVContent(allPangeaMessages, context, filename);
downloadFile(content, filename, DownloadType.csv);
return;
case DownloadType.xlsx:
final List<int> content =
getExcelContent(allPangeaMessages, context, filename);
downloadFile(content, filename, DownloadType.xlsx);
return;
}
}
Future<List<Event>> getAllEvents(Room room) async {
final GetRoomEventsResponse initalResp =
await room.client.getRoomEvents(room.id, Direction.b);
if (initalResp.end == null) return [];
String? nextStartToken = initalResp.end;
List<MatrixEvent> allMatrixEvents = initalResp.chunk;
while (nextStartToken != null) {
final GetRoomEventsResponse resp = await room.client.getRoomEvents(
room.id,
Direction.b,
from: nextStartToken,
);
final chunkMessages = resp.chunk;
allMatrixEvents.addAll(chunkMessages);
resp.end != nextStartToken
? nextStartToken = resp.end
: nextStartToken = null;
}
allMatrixEvents = allMatrixEvents.reversed.toList();
final List<Event> allEvents = allMatrixEvents
.map((MatrixEvent message) => Event.fromMatrixEvent(message, room))
.toList();
return allEvents;
}
List<PangeaMessageEvent> getPangeaMessageEvents(
List<Event> events,
Timeline timeline,
Room room,
) {
final List<PangeaMessageEvent> allPangeaMessages = events
.where(
(Event event) =>
event.type == EventTypes.Message &&
event.content['msgtype'] == MessageTypes.Text,
)
.map(
(Event message) => PangeaMessageEvent(
event: message,
timeline: timeline,
ownMessage: false,
),
)
.cast<PangeaMessageEvent>()
.toList();
return allPangeaMessages;
}
String getSentText(PangeaMessageEvent message) =>
message.originalSent?.text ?? message.body;
bool usageIsAvailable(PangeaMessageEvent message) {
try {
return message.originalSent?.choreo != null;
} catch (err) {
return false;
}
}
String getFilename(Room room, DownloadType type) {
final String roomName = room
.getLocalizedDisplayname()
.trim()
.replaceAll(RegExp(r'[^A-Za-z0-9\s]'), "")
.replaceAll(RegExp(r'\s+'), "-");
final String timestamp =
DateFormat('yyyy-MM-dd-hh:mm:ss').format(DateTime.now());
final String extension = type == DownloadType.txt
? 'txt'
: type == DownloadType.csv
? 'csv'
: 'xlsx';
return "$roomName-$timestamp.$extension";
}
String mimetype(DownloadType fileType) {
switch (fileType) {
case DownloadType.txt:
return 'text/plain';
case DownloadType.csv:
return 'text/csv';
case DownloadType.xlsx:
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
}
String getTxtContent(
List<PangeaMessageEvent> messages,
BuildContext context,
String filename,
Room room,
) {
String formattedInfo = "";
for (final PangeaMessageEvent message in messages) {
final String timestamp =
DateFormat('yyyy-MM-dd hh:mm:ss').format(message.originServerTs);
final String sender = message.senderId;
final String originalMsg = message.originalWrittenContent;
final String sentMsg = getSentText(message);
final bool usageAvailable = usageIsAvailable(message);
if (!usageAvailable) {
formattedInfo +=
"${L10n.of(context).sender}: $sender\n${L10n.of(context).time}: $timestamp\n${L10n.of(context).originalMessage}: $originalMsg\n${L10n.of(context).sentMessage}: $sentMsg\n${L10n.of(context).useType}: ${L10n.of(context).notAvailable}\n\n";
continue;
}
final bool includedIT = message.originalSent!.choreo!.includedIT;
final bool includedIGC = message.originalSent!.choreo!.includedIGC;
formattedInfo +=
"${L10n.of(context).sender}: $sender\n${L10n.of(context).time}: $timestamp\n${L10n.of(context).originalMessage}: $originalMsg\n${L10n.of(context).sentMessage}: $sentMsg\n${L10n.of(context).useType}: ";
if (includedIT && includedIGC) {
formattedInfo += L10n.of(context).taAndGaTooltip;
} else if (includedIT) {
formattedInfo += L10n.of(context).taTooltip;
} else if (includedIGC) {
formattedInfo += L10n.of(context).gaTooltip;
} else {
formattedInfo += L10n.of(context).waTooltip;
}
formattedInfo += "\n\n";
}
formattedInfo = "${room.getLocalizedDisplayname()}\n\n$formattedInfo";
return formattedInfo;
}
String getCSVContent(
List<PangeaMessageEvent> messages,
BuildContext context,
String fileName,
) {
final List<List<String>> csvData = [
[
L10n.of(context).sender,
L10n.of(context).time,
L10n.of(context).originalMessage,
L10n.of(context).sentMessage,
L10n.of(context).taTooltip,
L10n.of(context).gaTooltip,
]
];
for (final PangeaMessageEvent message in messages) {
final String timestamp =
DateFormat('yyyy-MM-dd hh:mm:ss').format(message.originServerTs);
final String sender = message.senderId;
final String originalMsg = message.originalWrittenContent;
final String sentMsg = getSentText(message);
final bool usageAvailable = usageIsAvailable(message);
if (!usageAvailable) {
csvData.add([
sender,
timestamp,
originalMsg,
sentMsg,
L10n.of(context).notAvailable,
L10n.of(context).notAvailable,
]);
continue;
}
final bool includedIT = message.originalSent!.choreo!.includedIT;
final bool includedIGC = message.originalSent!.choreo!.includedIGC;
csvData.add([
sender,
timestamp,
originalMsg,
sentMsg,
includedIT.toString(),
includedIGC.toString(),
]);
}
final String fileString = const ListToCsvConverter().convert(csvData);
return fileString;
}
List<int> getExcelContent(
List<PangeaMessageEvent> messages,
BuildContext context,
String filename,
) {
final excel = Excel.createExcel();
final Sheet sheetObject = excel['Sheet1'];
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0))
.value = TextCellValue(L10n.of(context).sender);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 0))
.value = TextCellValue(L10n.of(context).time);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: 0))
.value = TextCellValue(L10n.of(context).originalMessage);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 0))
.value = TextCellValue(L10n.of(context).sentMessage);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: 0))
.value = TextCellValue(L10n.of(context).taTooltip);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: 0))
.value = TextCellValue(L10n.of(context).gaTooltip);
for (int i = 0; i < messages.length; i++) {
final PangeaMessageEvent message = messages[i];
final String sender = message.senderId;
final String originalMsg = message.originalWrittenContent;
final String sentMsg = getSentText(message);
final bool usageAvailable = usageIsAvailable(message);
bool includedIT = false;
bool includedIGC = false;
if (usageAvailable) {
includedIT = message.originalSent!.choreo!.includedIT;
includedIGC = message.originalSent!.choreo!.includedIGC;
}
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: i + 2))
.value = TextCellValue(sender);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: i + 2))
.value = DateTimeCellValue(
year: message.originServerTs.year,
month: message.originServerTs.month,
day: message.originServerTs.day,
hour: message.originServerTs.hour,
minute: message.originServerTs.minute,
second: message.originServerTs.second,
);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: i + 2))
.value = TextCellValue(originalMsg);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: i + 2))
.value = TextCellValue(sentMsg);
if (usageAvailable) {
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: i + 2))
.value = TextCellValue(includedIT.toString());
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: i + 2))
.value = TextCellValue(includedIGC.toString());
}
}
final List<int>? bytes = excel.encode();
return bytes ?? [];
}

View file

@ -1,59 +0,0 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:open_file/open_file.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:universal_html/html.dart' as webfile;
import 'package:fluffychat/pangea/chat_settings/utils/download_chat.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
enum DownloadType { txt, csv, xlsx }
Future<void> downloadFile(
dynamic contents,
String filename,
DownloadType fileType,
) async {
if (kIsWeb) {
final blob = webfile.Blob([contents], mimetype(fileType), 'native');
webfile.AnchorElement(
href: webfile.Url.createObjectUrlFromBlob(blob).toString(),
)
..setAttribute("download", filename)
..click();
return;
}
if (await Permission.storage.request().isGranted) {
Directory? directory;
try {
if (Platform.isIOS) {
directory = await getApplicationDocumentsDirectory();
} else {
directory = Directory('/storage/emulated/0/Download');
if (!await directory.exists()) {
directory = await getExternalStorageDirectory();
}
}
} catch (err, s) {
debugPrint("Failed to get download folder path");
ErrorHandler.logError(
e: err,
s: s,
data: {},
);
}
if (directory != null) {
final File f = File("${directory.path}/$filename");
File resp;
if (fileType == DownloadType.txt || fileType == DownloadType.csv) {
resp = await f.writeAsString(contents);
} else {
resp = await f.writeAsBytes(contents);
}
OpenFile.open(resp.path);
}
}
}

View file

@ -112,7 +112,7 @@ class ConstructIdentifier {
@override
int get hashCode {
return lemma.hashCode ^ type.hashCode;
return lemma.hashCode ^ type.hashCode ^ category.hashCode;
}
String get string {

View file

@ -0,0 +1,59 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:open_file/open_file.dart';
import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:universal_html/html.dart' as webfile;
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/download/download_type_enum.dart';
class DownloadUtil {
static Future<void> downloadFile(
dynamic contents,
String filename,
DownloadType fileType,
) async {
if (kIsWeb) {
final blob = webfile.Blob([contents], fileType.mimetype, 'native');
webfile.AnchorElement(
href: webfile.Url.createObjectUrlFromBlob(blob).toString(),
)
..setAttribute("download", filename)
..click();
return;
}
if (await Permission.storage.request().isGranted) {
Directory? directory;
try {
if (Platform.isIOS) {
directory = await getApplicationDocumentsDirectory();
} else {
directory = Directory('/storage/emulated/0/Download');
if (!await directory.exists()) {
directory = await getExternalStorageDirectory();
}
}
} catch (err, s) {
debugPrint("Failed to get download folder path");
ErrorHandler.logError(
e: err,
s: s,
data: {},
);
}
if (directory != null) {
final File f = File("${directory.path}/$filename");
File resp;
if (fileType == DownloadType.txt || fileType == DownloadType.csv) {
resp = await f.writeAsString(contents);
} else {
resp = await f.writeAsBytes(contents);
}
OpenFile.open(resp.path);
}
}
}
}

View file

@ -0,0 +1,241 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:csv/csv.dart';
import 'package:excel/excel.dart';
import 'package:intl/intl.dart';
import 'package:matrix/matrix.dart';
import 'package:matrix/src/models/timeline_chunk.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/download/download_file_util.dart';
import 'package:fluffychat/pangea/download/download_type_enum.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
class _EventDownloadInfo {
final String sender;
final DateTime timestamp;
final String originalMsg;
final String sentMsg;
final bool usageAvailable;
final bool includedIT;
final bool includedIGC;
_EventDownloadInfo({
required this.sender,
required this.timestamp,
required this.originalMsg,
required this.sentMsg,
required this.usageAvailable,
required this.includedIT,
required this.includedIGC,
});
}
class EmptyChatException implements Exception {}
extension DownloadExtension on Room {
Future<void> download(
DownloadType type,
BuildContext context,
) async {
List<PangeaMessageEvent> allPangeaMessages;
final List<Event> allEvents = await getAllEvents();
final TimelineChunk chunk = TimelineChunk(events: allEvents);
final Timeline timeline = Timeline(
room: this,
chunk: chunk,
);
allPangeaMessages = getPangeaMessageEvents(
allEvents,
timeline,
);
if (allPangeaMessages.isEmpty) {
throw EmptyChatException();
}
dynamic content;
final List<_EventDownloadInfo> eventInfo = allPangeaMessages.map((message) {
return _EventDownloadInfo(
sender: message.senderId,
timestamp: message.originServerTs,
originalMsg: message.originalWrittenContent,
sentMsg: message.originalSent?.text ?? message.body,
usageAvailable: message.originalSent?.choreo != null,
includedIT: message.originalSent?.choreo?.includedIT ?? false,
includedIGC: message.originalSent?.choreo?.includedIGC ?? false,
);
}).toList();
switch (type) {
case DownloadType.txt:
content = _getTxtContent(eventInfo, context);
case DownloadType.csv:
content = _getCSVContent(eventInfo, context);
case DownloadType.xlsx:
content = _getExcelContent(eventInfo, context);
}
DownloadUtil.downloadFile(content, _getFilename(type), type);
}
String _getFilename(DownloadType type) {
final String roomName = getLocalizedDisplayname()
.trim()
.replaceAll(RegExp(r'[^A-Za-z0-9\s]'), "")
.replaceAll(RegExp(r'\s+'), "-");
final String timestamp =
DateFormat('yyyy-MM-dd-hh:mm:ss').format(DateTime.now());
return "$roomName-$timestamp.${type.extension}";
}
String _getTxtContent(
List<_EventDownloadInfo> eventInfo,
BuildContext context,
) {
String formattedInfo = "";
final l10n = L10n.of(context);
for (final _EventDownloadInfo info in eventInfo) {
final String timestamp =
DateFormat('yyyy-MM-dd hh:mm:ss').format(info.timestamp);
if (!info.usageAvailable) {
formattedInfo +=
"${l10n.sender}: ${info.sender}\n${l10n.time}: $timestamp\n${l10n.originalMessage}: ${info.originalMsg}\n${l10n.sentMessage}: ${info.sentMsg}\n${l10n.useType}: ${l10n.notAvailable}\n\n";
continue;
}
formattedInfo +=
"${l10n.sender}: ${info.sender}\n${l10n.time}: $timestamp\n${l10n.originalMessage}: ${info.originalMsg}\n${l10n.sentMessage}: ${info.sentMsg}\n${l10n.useType}: ";
if (info.includedIT && info.includedIGC) {
formattedInfo += l10n.taAndGaTooltip;
} else if (info.includedIT) {
formattedInfo += l10n.taTooltip;
} else if (info.includedIGC) {
formattedInfo += l10n.gaTooltip;
} else {
formattedInfo += l10n.waTooltip;
}
formattedInfo += "\n\n";
}
formattedInfo = "${getLocalizedDisplayname()}\n\n$formattedInfo";
return formattedInfo;
}
String _getCSVContent(
List<_EventDownloadInfo> eventInfo,
BuildContext context,
) {
final l10n = L10n.of(context);
final List<List<String>> csvData = [
[
l10n.sender,
l10n.time,
l10n.originalMessage,
l10n.sentMessage,
l10n.taTooltip,
l10n.gaTooltip,
]
];
for (final _EventDownloadInfo info in eventInfo) {
final String timestamp =
DateFormat('yyyy-MM-dd hh:mm:ss').format(info.timestamp);
if (!info.usageAvailable) {
csvData.add([
info.sender,
timestamp,
info.originalMsg,
info.sentMsg,
l10n.notAvailable,
l10n.notAvailable,
]);
continue;
}
csvData.add([
info.sender,
timestamp,
info.originalMsg,
info.sentMsg,
info.includedIT.toString(),
info.includedIGC.toString(),
]);
}
final String fileString = const ListToCsvConverter().convert(csvData);
return fileString;
}
List<int> _getExcelContent(
List<_EventDownloadInfo> eventInfo,
BuildContext context,
) {
final l10n = L10n.of(context);
final excel = Excel.createExcel();
final Sheet sheetObject = excel['Sheet1'];
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: 0))
.value = TextCellValue(l10n.sender);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: 0))
.value = TextCellValue(l10n.time);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: 0))
.value = TextCellValue(l10n.originalMessage);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: 0))
.value = TextCellValue(l10n.sentMessage);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: 0))
.value = TextCellValue(l10n.taTooltip);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: 0))
.value = TextCellValue(l10n.gaTooltip);
for (int i = 0; i < eventInfo.length; i++) {
final _EventDownloadInfo info = eventInfo[i];
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 0, rowIndex: i + 2))
.value = TextCellValue(info.sender);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 1, rowIndex: i + 2))
.value = DateTimeCellValue(
year: info.timestamp.year,
month: info.timestamp.month,
day: info.timestamp.day,
hour: info.timestamp.hour,
minute: info.timestamp.minute,
second: info.timestamp.second,
);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 2, rowIndex: i + 2))
.value = TextCellValue(info.originalMsg);
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 3, rowIndex: i + 2))
.value = TextCellValue(info.sentMsg);
if (info.usageAvailable) {
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 4, rowIndex: i + 2))
.value = TextCellValue(info.includedIT.toString());
sheetObject
.cell(CellIndex.indexByColumnRow(columnIndex: 5, rowIndex: i + 2))
.value = TextCellValue(info.includedIGC.toString());
}
}
final List<int>? bytes = excel.encode();
return bytes ?? [];
}
}

View file

@ -0,0 +1,27 @@
enum DownloadType {
txt,
csv,
xlsx;
String get mimetype {
switch (this) {
case DownloadType.txt:
return 'text/plain';
case DownloadType.csv:
return 'text/csv';
case DownloadType.xlsx:
return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
}
}
String get extension {
switch (this) {
case DownloadType.txt:
return 'txt';
case DownloadType.csv:
return 'csv';
case DownloadType.xlsx:
return 'xlsx';
}
}
}

View file

@ -9,6 +9,7 @@ import 'package:matrix/matrix.dart';
import 'package:matrix/src/utils/markdown.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/choreographer/event_wrappers/pangea_choreo_event.dart';
import 'package:fluffychat/pangea/choreographer/models/choreo_record.dart';
import 'package:fluffychat/pangea/choreographer/models/language_detection_model.dart';
@ -336,4 +337,22 @@ class RepresentationEvent {
.toList() ??
[];
}
List<OneConstructUse> vocabAndMorphUses() {
if (tokens == null || tokens!.isEmpty) {
return [];
}
final metadata = ConstructUseMetaData(
roomId: parentMessageEvent.room.id,
timeStamp: parentMessageEvent.originServerTs,
eventId: parentMessageEvent.eventId,
);
return content.vocabAndMorphUses(
tokens: tokens!,
metadata: metadata,
choreo: choreo,
);
}
}

View file

@ -9,7 +9,6 @@ import 'package:fluffychat/pangea/choreographer/models/pangea_match_model.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/models/pangea_token_model.dart';
import 'package:fluffychat/pangea/toolbar/models/speech_to_text_models.dart';
import 'package:fluffychat/widgets/matrix.dart';
/// this class is contained within a [RepresentationEvent]
/// this event is the child of a [EventTypes.Message]
@ -105,8 +104,6 @@ class PangeaRepresentation {
ChoreoRecord? choreo,
}) {
final List<OneConstructUse> uses = [];
final l2 = MatrixState.pangeaController.languageController.userL2;
if (langCode.split("-")[0] != l2?.langCodeShort) return uses;
// missing vital info so return
if (event?.roomId == null && metadata?.roomId == null) {

View file

@ -24,6 +24,7 @@ import 'package:fluffychat/pangea/chat_settings/constants/pangea_room_types.dart
import 'package:fluffychat/pangea/chat_settings/models/bot_options_model.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/events/models/tokens_event_content_model.dart';
import 'package:fluffychat/pangea/extensions/join_rule_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';

View file

@ -321,4 +321,55 @@ extension EventsRoomExtension on Room {
return resp.chunk.map((e) => Event.fromMatrixEvent(e, this)).toList();
}
Future<List<Event>> getAllEvents() async {
final GetRoomEventsResponse initalResp =
await client.getRoomEvents(id, Direction.b);
if (initalResp.end == null) return [];
String? nextStartToken = initalResp.end;
List<MatrixEvent> allMatrixEvents = initalResp.chunk;
while (nextStartToken != null) {
final GetRoomEventsResponse resp = await client.getRoomEvents(
id,
Direction.b,
from: nextStartToken,
);
final chunkMessages = resp.chunk;
allMatrixEvents.addAll(chunkMessages);
resp.end != nextStartToken
? nextStartToken = resp.end
: nextStartToken = null;
}
allMatrixEvents = allMatrixEvents.reversed.toList();
final List<Event> allEvents = allMatrixEvents
.map((MatrixEvent message) => Event.fromMatrixEvent(message, this))
.toList();
return allEvents;
}
List<PangeaMessageEvent> getPangeaMessageEvents(
List<Event> events,
Timeline timeline,
) {
final List<PangeaMessageEvent> allPangeaMessages = events
.where(
(Event event) =>
event.type == EventTypes.Message &&
event.content['msgtype'] == MessageTypes.Text,
)
.map(
(Event message) => PangeaMessageEvent(
event: message,
timeline: timeline,
ownMessage: client.userID == message.senderId,
),
)
.cast<PangeaMessageEvent>()
.toList();
return allPangeaMessages;
}
}

View file

@ -12,9 +12,10 @@ import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_mo
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_use_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/chat_settings/utils/download_file.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
import 'package:fluffychat/pangea/download/download_file_util.dart';
import 'package:fluffychat/pangea/download/download_type_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -143,7 +144,7 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
final fileName =
"analytics_${widget.space.name}_${DateTime.now().toIso8601String()}.${_downloadType == DownloadType.xlsx ? 'xlsx' : 'csv'}";
await downloadFile(
await DownloadUtil.downloadFile(
content,
fileName,
DownloadType.csv,