feat: space analytics page

This commit is contained in:
ggurdin 2025-08-06 15:18:10 -04:00 committed by GitHub
parent f4f1113277
commit 39fc047961
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 2288 additions and 177 deletions

View file

@ -43,6 +43,7 @@ import 'package:fluffychat/pangea/login/pages/signup.dart';
import 'package:fluffychat/pangea/login/pages/space_code_onboarding.dart';
import 'package:fluffychat/pangea/login/pages/user_settings.dart';
import 'package:fluffychat/pangea/onboarding/onboarding.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/pangea/spaces/utils/join_with_alias.dart';
import 'package:fluffychat/pangea/spaces/utils/join_with_link.dart';
@ -619,6 +620,17 @@ abstract class AppRoutes {
),
routes: [
// #Pangea
GoRoute(
path: '/analytics',
redirect: loggedOutRedirect,
pageBuilder: (context, state) => defaultPageBuilder(
context,
state,
SpaceAnalytics(
roomId: state.pathParameters['roomid']!,
),
),
),
GoRoute(
path: 'planner',
pageBuilder: (context, state) => defaultPageBuilder(

View file

@ -4199,7 +4199,7 @@
"roomAddedToSpace": "Room(s) have been added to the selected space.",
"addChatToSpaceDesc": "Adding a chat to a space will make the chat appear within the space for students and give them access.",
"addSpaceToSpaceDesc": "Adding a sub space to space will make the sub space appear in the main space's chat list.",
"spaceAnalytics": "Space Analytics",
"spaceAnalytics": "Space analytics",
"changeAnalyticsLanguage": "Change Analytics Language",
"suggestToSpace": "Suggest this space",
"suggestToSpaceDesc": "Suggested sub spaces will appear in their main space's chat list",
@ -5093,6 +5093,22 @@
"errorFetchingActivity": "Failed to fetch activity",
"check": "Check",
"unableToFindRoom": "No chat or space found with that code. Please try again.",
"spaceAnalyticsPage": "Space analytics page",
"numCompletedActivities": "Number of completed activities",
"viewingAnalytics": "Viewing {visible}/{users} Analytics",
"@viewingAnalytics": {
"type": "String",
"placeholders": {
"visible": {
"type": "int"
},
"users": {
"type": "int"
}
}
},
"request": "Request",
"requestAll": "Request All",
"confirmMessageUnpin": "Are you sure you want to unpin this message?",
"createActivityPlan": "Create a new activity plan",
"saveAndLaunch": "Save and Launch",
@ -5107,6 +5123,8 @@
}
}
},
"pending": "Pending",
"inactive": "Inactive",
"unjoinedActivityMessage": "Do you want to participate? Choose an open role!\nOr hang out and watch the show!",
"fullActivityMessage": "Feel free to watch the show! While there aren't any open roles to participate, you can view the chat!",
"confirmRole": "Confirm role",
@ -5137,5 +5155,41 @@
"archiveToAnalytics": "Add to my Completed Activities",
"activitySummaryError": "Activity summaries unavailable",
"requestSummaries": "Request summaries",
"loadingActivitySummary": "Loading activity summary..."
"loadingActivitySummary": "Loading activity summary...",
"requestAccessTitle": "Request to analytics view access?",
"requestAccessDesc": "Would you like to request access to view participant analytics?\n\nIf participants agree, admin of this space will be able to view their:\n • total vocabulary\n • total grammar concepts\n • total activity sessions completed\n • the specific grammar concepts used, correctly and incorrectly\n\n • They will not be able to view their:\n • messages in chats outside the space\n • vocabulary list",
"requestAccess": "Request access ({count})",
"@requestAccess": {
"type": "String",
"placeholders": {
"count": {
"type": "int"
}
}
},
"analyticsInactiveTitle": "Requests to inactive users couldnt be sent",
"analyticsInactiveDesc": "Inactive users who haven't logged in since this feature was introduced won't see your request.\n\nThe Request button will appear once they return. You can resend the request later by clicking the Request button under their name when it's available.",
"accessRequestedTitle": "Analytics Access Request",
"accessRequestedDesc": "The administrators of “{space}” are requesting to view your learning analytics.\n\nIf you agree, admin of this space will be able to view your:\n • total vocabulary\n • total grammar concepts\n • total activity sessions completed\n • the specific grammar concepts used, correctly and incorrectly\n\n • They will not be able to view your:\n • messages in chats outside the space\n • vocabulary list",
"@accessRequestedDesc": {
"type": "String",
"placeholders": {
"space": {
"type": "String"
}
}
},
"allowAccess": "Allow Access",
"denyAccess": "Deny Access",
"adminRequestedAccess": "Admins asked to view your analytics.",
"lastUpdated": "Updated\n{time}",
"@lastUpdated": {
"type": "String",
"placeholders": {
"time": {
"type": "String"
}
}
},
"noDataFound": "No data found"
}

View file

@ -18,6 +18,7 @@ import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/public_spaces/public_room_bottom_sheet.dart';
import 'package:fluffychat/pangea/space_analytics/analytics_request_indicator.dart';
import 'package:fluffychat/pangea/spaces/constants/space_constants.dart';
import 'package:fluffychat/pangea/spaces/widgets/knocking_users_indicator.dart';
import 'package:fluffychat/pangea/spaces/widgets/leaderboard_participant_list.dart';
@ -804,6 +805,7 @@ class _SpaceViewState extends State<SpaceView> {
);
},
),
AnalyticsRequestIndicator(room: room),
// Pangea#
SliverList.builder(
itemCount: joinedRooms.length,

View file

@ -42,6 +42,9 @@ enum SpaceAnalyticsSummaryEnum {
// list morph > 500 XP
listMorphHugeXP,
/// Number of completed activities
numCompletedActivities,
}
extension AnalyticsSummaryEnumExtension on SpaceAnalyticsSummaryEnum {
@ -95,6 +98,8 @@ extension AnalyticsSummaryEnumExtension on SpaceAnalyticsSummaryEnum {
return l10n.numCorrectChoices;
case SpaceAnalyticsSummaryEnum.numChoicesIncorrect:
return l10n.numIncorrectChoices;
case SpaceAnalyticsSummaryEnum.numCompletedActivities:
return l10n.numCompletedActivities;
}
}
}

View file

@ -50,6 +50,8 @@ class SpaceAnalyticsSummaryModel {
int? numChoicesCorrect;
int? numChoicesIncorrect;
int numCompletedActivities;
SpaceAnalyticsSummaryModel({
required this.username,
required this.dataAvailable,
@ -75,6 +77,7 @@ class SpaceAnalyticsSummaryModel {
this.numWordsTyped,
this.numChoicesCorrect,
this.numChoicesIncorrect,
this.numCompletedActivities = 0,
});
static SpaceAnalyticsSummaryModel emptyModel(String userID) {
@ -87,6 +90,7 @@ class SpaceAnalyticsSummaryModel {
static SpaceAnalyticsSummaryModel fromConstructListModel(
String userID,
ConstructListModel? model,
int numCompletedActivities,
String Function(ConstructUses) getCopy,
BuildContext context,
) {
@ -207,6 +211,7 @@ class SpaceAnalyticsSummaryModel {
numWordsTyped: numWordsTyped,
numChoicesCorrect: numChoicesCorrect,
numChoicesIncorrect: numChoicesIncorrect,
numCompletedActivities: numCompletedActivities,
);
}
@ -262,6 +267,8 @@ class SpaceAnalyticsSummaryModel {
return numChoicesCorrect;
case SpaceAnalyticsSummaryEnum.numChoicesIncorrect:
return numChoicesIncorrect;
case SpaceAnalyticsSummaryEnum.numCompletedActivities:
return numCompletedActivities;
}
}
@ -290,6 +297,7 @@ class SpaceAnalyticsSummaryModel {
'numWordsWithoutAssistance': numWordsTyped,
'numChoicesCorrect': numChoicesCorrect,
'numChoicesIncorrect': numChoicesIncorrect,
'numCompletedActivities': numCompletedActivities,
};
}
}

View file

@ -87,44 +87,35 @@ extension AnalyticsClientExtension on Client {
}
/// Get all my analytics rooms
List<Room> get _allMyAnalyticsRooms => rooms
List<Room> get allMyAnalyticsRooms => rooms
.where(
(e) => e.isAnalyticsRoomOfUser(userID!),
)
.toList();
/// Update the visibility of all analytics rooms to private (do they don't show in search
/// results) and set the join rules to public (so they come through in space hierarchy response)
Future<void> updateAnalyticsRoomVisibility() async {
/// Update the join rules of all analytics rooms to 'knock'.
Future<void> updateAnalyticsRoomJoinRules() async {
if (prevBatch == null) await onSync.stream.first;
if (userID == null || userID == BotName.byEnvironment) return;
final Random random = Random();
for (final analyticsRoom in _allMyAnalyticsRooms) {
if (userID == null) return;
final visibility = await getRoomVisibilityOnDirectory(analyticsRoom.id);
for (final analyticsRoom in allMyAnalyticsRooms) {
if (!isLogged()) return;
if (analyticsRoom.joinRules == JoinRules.knock) continue;
// if making a call to the server (either to update visibility or join rules)
// add a delay at the end of this interaction to prevent overloading the server
int delay = 0;
if (visibility != Visibility.private ||
analyticsRoom.joinRules != JoinRules.public) {
delay = random.nextInt(10);
}
await analyticsRoom.setJoinRules(JoinRules.knock);
await Future.delayed(Duration(seconds: random.nextInt(10)));
}
}
// don't show in search results
if (visibility != Visibility.private) {
await setRoomVisibilityOnDirectory(
analyticsRoom.id,
visibility: Visibility.private,
);
}
Future<void> loadAnalyticsRequests() async {
if (prevBatch == null) await onSync.stream.first;
if (userID == null || userID == BotName.byEnvironment) return;
// do show in space hierarchy
if (analyticsRoom.joinRules != JoinRules.public) {
await analyticsRoom.setJoinRules(JoinRules.public);
}
await Future.delayed(Duration(seconds: delay));
for (final analyticsRoom in allMyAnalyticsRooms) {
if (!isLogged()) return;
analyticsRoom.requestParticipants([Membership.knock], false, true);
analyticsRoom.postLoad();
}
}
@ -141,7 +132,7 @@ extension AnalyticsClientExtension on Client {
final Random random = Random();
for (final space in spaces) {
if (userID == null || !space.canSendEvent(EventTypes.SpaceChild)) return;
final List<Room> roomsNotAdded = _allMyAnalyticsRooms.where((room) {
final List<Room> roomsNotAdded = allMyAnalyticsRooms.where((room) {
return !space.spaceChildren.any((child) => child.roomId == room.id);
}).toList();

View file

@ -76,8 +76,9 @@ class GetAnalyticsController extends BaseController {
try {
await GetStorage.init("analytics_storage");
_client.updateAnalyticsRoomVisibility();
_client.updateAnalyticsRoomJoinRules();
_client.addAnalyticsRoomsToSpaces();
_client.loadAnalyticsRequests();
_analyticsUpdateSubscription ??= _pangeaController
.putAnalytics.analyticsUpdateStream.stream

View file

@ -16,7 +16,7 @@ extension AnalyticsRoomExtension on Room {
///
/// Yield this list of rooms.
/// Once analytics have been retrieved, leave analytics rooms not created by self.
Stream<List<Room>> getNextAnalyticsRoomBatch(String userL2) async* {
Stream<List<Room>> getNextAnalyticsRoomBatch(String langCode) async* {
final List<SpaceRoomsChunk> rooms = [];
String? nextBatch;
int spaceHierarchyCalls = 0;
@ -50,8 +50,8 @@ extension AnalyticsRoomExtension on Room {
);
final (analyticsRoom, calls) = matchingRoom != null
? await _handleJoinedAnalyticsRoom(matchingRoom, userL2)
: await _handleUnjoinedAnalyticsRoom(nextRoomChunk, userL2);
? await _handleJoinedAnalyticsRoom(matchingRoom, langCode)
: await _handleUnjoinedAnalyticsRoom(nextRoomChunk, langCode);
callsToServer += calls;
if (analyticsRoom == null) continue;

View file

@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
import 'package:fluffychat/pangea/analytics_misc/get_analytics_controller.dart';
@ -12,6 +13,7 @@ import 'package:fluffychat/pangea/analytics_summary/learning_progress_bar.dart';
import 'package:fluffychat/pangea/analytics_summary/learning_progress_indicator_button.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicator.dart';
import 'package:fluffychat/pangea/analytics_summary/progress_indicators_enum.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/pages/settings_learning.dart';
import 'package:fluffychat/widgets/hover_builder.dart';
import 'package:fluffychat/widgets/matrix.dart';
@ -153,8 +155,12 @@ class LearningProgressIndicatorsState
),
const SizedBox(width: 6.0),
AnimatedFloatingNumber(
number: MatrixState.pangeaController
.getAnalytics.archivedActivities.length,
number: Matrix.of(context)
.client
.analyticsRoomLocal()
?.activityRoomIds
.length ??
0,
),
],
),

View file

@ -22,7 +22,6 @@ import 'package:fluffychat/pangea/chat_settings/widgets/delete_space_dialog.dart
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/spaces/utils/load_participants_util.dart';
import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.dart';
import 'package:fluffychat/utils/fluffy_share.dart';
import 'package:fluffychat/utils/matrix_sdk_extensions/matrix_locals.dart';
import 'package:fluffychat/utils/url_launcher.dart';
@ -384,16 +383,12 @@ class RoomDetailsButtonRowState extends State<RoomDetailsButtonRow> {
showInMainView: false,
),
ButtonDetails(
title: l10n.downloadSpaceAnalytics,
icon: const Icon(Icons.download_outlined, size: 30.0),
onPressed: () {
showDialog(
context: context,
builder: (context) => DownloadAnalyticsDialog(space: room),
);
},
visible: room.isSpace && room.isRoomAdmin && kIsWeb,
showInMainView: false,
title: l10n.spaceAnalytics,
icon: const Icon(Icons.bar_chart, size: 30.0),
onPressed: () => context.go('/rooms/${room.id}/details/analytics'),
visible: room.isSpace,
enabled: room.isSpace && room.isRoomAdmin,
showInMainView: true,
),
ButtonDetails(
title: l10n.download,

View file

@ -1,48 +0,0 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.dart';
class DownloadSpaceAnalyticsButton extends StatelessWidget {
final Room space;
const DownloadSpaceAnalyticsButton({
super.key,
required this.space,
});
@override
Widget build(BuildContext context) {
if (!kIsWeb) {
return const SizedBox.shrink();
}
final iconColor = Theme.of(context).textTheme.bodyLarge!.color;
return Column(
children: [
ListTile(
onTap: () {
showDialog(
context: context,
builder: (context) => DownloadAnalyticsDialog(space: space),
);
},
leading: CircleAvatar(
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
foregroundColor: iconColor,
child: const Icon(Icons.download_outlined),
),
title: Text(
L10n.of(context).downloadSpaceAnalytics,
style: TextStyle(
color: Theme.of(context).colorScheme.secondary,
fontWeight: FontWeight.bold,
),
),
),
],
);
}
}

View file

@ -154,6 +154,7 @@ class ModelKey {
static const String analytics = "analytics";
static const String level = "level";
static const String xpOffset = "xp_offset";
static const String analyticsRoomId = "analytics_room_id";
// activity plan
static const String activityPlanRequest = "req";

View file

@ -127,6 +127,7 @@ class PangeaController {
'subscription_storage',
'vocab_storage',
'onboarding_storage',
'analytics_request_storage',
];
Future<void> clearCache({List<String> exclude = const []}) async {

View file

@ -8,11 +8,13 @@ class FullWidthDialog extends StatelessWidget {
final Widget dialogContent;
final double maxWidth;
final double maxHeight;
final Color? backgroundColor;
const FullWidthDialog({
required this.dialogContent,
required this.maxWidth,
required this.maxHeight,
this.backgroundColor,
super.key,
});
@ -42,8 +44,14 @@ class FullWidthDialog extends StatelessWidget {
return BackdropFilter(
filter: ImageFilter.blur(sigmaX: 2.5, sigmaY: 2.5),
child: isColumnMode
? Dialog(child: content)
: Dialog.fullscreen(child: content),
? Dialog(
backgroundColor: backgroundColor,
child: content,
)
: Dialog.fullscreen(
backgroundColor: backgroundColor,
child: content,
),
);
}
}

View file

@ -0,0 +1,14 @@
import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_model.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart';
class AnalyticsDownload {
RequestStatus requestStatus;
DownloadStatus downloadStatus;
SpaceAnalyticsSummaryModel? summary;
AnalyticsDownload({
required this.requestStatus,
required this.downloadStatus,
this.summary,
});
}

View file

@ -0,0 +1,126 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_requested_dialog.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
class AnalyticsRequestIndicator extends StatefulWidget {
final Room room;
const AnalyticsRequestIndicator({
super.key,
required this.room,
});
@override
AnalyticsRequestIndicatorState createState() =>
AnalyticsRequestIndicatorState();
}
class AnalyticsRequestIndicatorState extends State<AnalyticsRequestIndicator> {
AnalyticsRequestIndicatorState();
Map<User, List<Room>> get _knockingAdmins {
final admins =
widget.room.getParticipants().where((u) => u.powerLevel >= 100);
final knockingAdmins = <User, List<Room>>{};
for (final analyticsRoom in widget.room.client.allMyAnalyticsRooms) {
final knocking = analyticsRoom.getParticipants([Membership.knock]);
if (knocking.isEmpty) continue;
for (final admin in admins) {
if (knocking.any((u) => u.id == admin.id)) {
knockingAdmins.putIfAbsent(admin, () => []).add(analyticsRoom);
}
}
}
return knockingAdmins;
}
Future<void> _onTap(BuildContext context) async {
final resp = await showDialog(
context: context,
builder: (context) {
return SpaceAnalyticsRequestedDialog(room: widget.room);
},
);
if (resp is! bool) return;
await showFutureLoadingDialog(
context: context,
future: () async {
for (final entry in _knockingAdmins.entries) {
final user = entry.key;
final rooms = entry.value;
final List<Future> futures = rooms
.map((room) => resp ? room.invite(user.id) : room.kick(user.id))
.toList();
await Future.wait(futures);
}
},
);
if (mounted) setState(() {});
}
@override
Widget build(BuildContext context) {
return SliverList.builder(
itemCount: 1,
itemBuilder: (context, i) {
return AnimatedSize(
duration: FluffyThemes.animationDuration,
child: _knockingAdmins.isEmpty
? const SizedBox()
: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
child: Material(
borderRadius: BorderRadius.circular(
AppConfig.borderRadius,
),
clipBehavior: Clip.hardEdge,
child: ListTile(
minVerticalPadding: 0,
trailing: Icon(
Icons.arrow_right,
size: 20,
color: Theme.of(context).colorScheme.error,
),
title: Row(
spacing: 8.0,
children: [
Icon(
Icons.notifications_active_outlined,
color: Theme.of(context).colorScheme.error,
),
Expanded(
child: Text(
L10n.of(context).adminRequestedAccess,
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
),
onTap: () => _onTap(context),
),
),
),
);
},
);
}
}

View file

@ -0,0 +1,97 @@
import 'package:get_storage/get_storage.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart';
class _AnalyticsRequestEntry {
final RequestStatus status;
final DateTime timestamp;
_AnalyticsRequestEntry({
required this.status,
required this.timestamp,
});
Map<String, dynamic> toJson() {
return {
'status': status.name,
'timestamp': timestamp.toIso8601String(),
};
}
_AnalyticsRequestEntry.fromJson(Map<String, dynamic> json)
: status = RequestStatus.fromString(json['status']) ??
RequestStatus.unrequested,
timestamp = DateTime.parse(json['timestamp']);
bool get isExpired {
final now = DateTime.now();
const expirationDuration = Duration(days: 1);
return now.isAfter(timestamp.add(expirationDuration));
}
}
class AnalyticsRequestsRepo {
static final GetStorage _requestStorage =
GetStorage('analytics_request_storage');
static String _storageKey(String userId, LanguageModel language) {
return 'analytics_request_${userId}_${language.langCodeShort}';
}
static RequestStatus? get(String userId, LanguageModel language) {
final key = _storageKey(userId, language);
final entry = _requestStorage.read(key);
if (entry == null) {
return null;
}
final status = _AnalyticsRequestEntry.fromJson(entry);
if (status.isExpired) {
_requestStorage.remove(key);
return null;
}
return status.status;
}
static List<RequestStatus> getAll() {
final entries = _requestStorage.getValues();
final statuses = <RequestStatus>[];
for (final entry in entries) {
if (entry is Map<String, dynamic>) {
final status = _AnalyticsRequestEntry.fromJson(entry);
if (!status.isExpired) {
statuses.add(status.status);
} else {
// Remove expired entry
_requestStorage.remove(entry['key']);
}
}
}
return statuses.toSet().toList();
}
static Future<void> set(
String userId,
LanguageModel language,
RequestStatus status,
) async {
final key = _storageKey(userId, language);
final entry = _AnalyticsRequestEntry(
status: status,
timestamp: DateTime.now(),
);
await _requestStorage.write(key, entry.toJson());
}
static Future<void> remove(
String userId,
LanguageModel language,
) async {
final key = _storageKey(userId, language);
await _requestStorage.remove(key);
}
}

View file

@ -0,0 +1,455 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:get_storage/get_storage.dart';
import 'package:intl/intl.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_model.dart';
import 'package:fluffychat/pangea/analytics_misc/construct_list_model.dart';
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
import 'package:fluffychat/pangea/space_analytics/analytics_download_model.dart';
import 'package:fluffychat/pangea/space_analytics/analytics_requests_repo.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_inactive_dialog.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_request_dialog.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_view.dart';
import 'package:fluffychat/pangea/user/models/profile_model.dart';
import 'package:fluffychat/widgets/future_loading_dialog.dart';
import 'package:fluffychat/widgets/matrix.dart';
// enum DownloadStatus {
// loading,
// available,
// unavailable,
// notFound;
// }
// enum RequestStatus {
// available,
// unrequested,
// requested,
// notFound;
// static RequestStatus? fromString(String value) {
// switch (value) {
// case 'available':
// return RequestStatus.available;
// case 'unrequested':
// return RequestStatus.unrequested;
// case 'requested':
// return RequestStatus.requested;
// case 'notFound':
// return RequestStatus.notFound;
// default:
// return null;
// }
// }
// IconData get icon {
// switch (this) {
// case RequestStatus.available:
// return Icons.check_circle;
// case RequestStatus.unrequested:
// return Symbols.approval_delegation;
// case RequestStatus.requested:
// return Icons.mark_email_read_outlined;
// case RequestStatus.notFound:
// return Symbols.approval_delegation;
// }
// }
// String label(BuildContext context) {
// final l10n = L10n.of(context);
// switch (this) {
// case RequestStatus.available:
// return l10n.available;
// case RequestStatus.unrequested:
// return l10n.request;
// case RequestStatus.requested:
// return l10n.pending;
// case RequestStatus.notFound:
// return l10n.inactive;
// }
// }
// Color backgroundColor(BuildContext context) {
// final theme = Theme.of(context);
// switch (this) {
// case RequestStatus.available:
// case RequestStatus.unrequested:
// return theme.colorScheme.primaryContainer;
// case RequestStatus.notFound:
// case RequestStatus.requested:
// return theme.disabledColor;
// }
// }
// bool get showButton => this != RequestStatus.available;
// bool get enabled => this == RequestStatus.unrequested;
// }
// class AnalyticsDownload {
// DownloadStatus status;
// SpaceAnalyticsSummaryModel? summary;
// AnalyticsDownload({
// required this.status,
// this.summary,
// });
// }
class SpaceAnalytics extends StatefulWidget {
final String roomId;
const SpaceAnalytics({super.key, required this.roomId});
@override
SpaceAnalyticsState createState() => SpaceAnalyticsState();
}
class SpaceAnalyticsState extends State<SpaceAnalytics> {
bool initialized = false;
LanguageModel? selectedLanguage;
Map<User, AnalyticsDownload> downloads = {};
DateTime? _lastUpdated;
final Map<User, PublicProfileModel> _profiles = {};
final Map<LanguageModel, List<User>> _langsToUsers = {};
Room? get room => Matrix.of(context).client.getRoomById(widget.roomId);
LanguageModel? get _userL2 {
final l2 = MatrixState.pangeaController.languageController.userL2;
if (l2 == null) return null;
// Attempt to find the language model by its short code, since analytics
// aren't divided by full language model but by short code.
return PLanguageStore.byLangCode(l2.langCodeShort) ?? l2;
}
List<User> get _availableUsers =>
room
?.getParticipants()
.where(
(member) =>
member.id != BotName.byEnvironment &&
member.membership == Membership.join,
)
.toList() ??
[];
List<User> get _availableUsersForLang =>
_langsToUsers[selectedLanguage] ?? [];
List<Room> get availableAnalyticsRooms => _availableUsersForLang
.map((user) => _analyticsRoomOfUser(user))
.whereType<Room>()
.toList();
List<LanguageModel> get availableLanguages => _langsToUsers.keys.toList()
..sort((a, b) => a.langCode.compareTo(b.langCode));
int get completedDownloads =>
downloads.values.where((d) => d.summary != null).length;
List<MapEntry<User, AnalyticsDownload>> get sortedDownloads {
final entries = downloads.entries.toList();
entries.sort((a, b) {
final aStatus = a.value.requestStatus;
final bStatus = b.value.requestStatus;
// sort available downloads first
if (aStatus == RequestStatus.available &&
bStatus != RequestStatus.available) {
return -1;
} else if (aStatus != RequestStatus.available &&
bStatus == RequestStatus.available) {
return 1;
}
// then requestable users
if (aStatus == RequestStatus.unrequested &&
bStatus != RequestStatus.unrequested) {
return -1;
} else if (aStatus != RequestStatus.unrequested &&
bStatus == RequestStatus.unrequested) {
return 1;
}
// then sort not found to the end
if (aStatus == RequestStatus.unavailable &&
bStatus != RequestStatus.unavailable) {
return 1;
} else if (aStatus != RequestStatus.unavailable &&
bStatus == RequestStatus.unavailable) {
return -1;
}
return 0;
});
return entries;
}
String? get lastUpdatedString {
if (_lastUpdated == null) return null;
final now = DateTime.now();
final difference = now.difference(_lastUpdated!);
return difference.inDays > 0
? DateFormat('yyyy-MM-dd').format(_lastUpdated!)
: DateFormat('HH:mm a').format(_lastUpdated!);
}
@override
void initState() {
super.initState();
_initialize();
}
Future<void> _initialize() async {
final List<Future> futures = [
GetStorage.init('analytics_request_storage'),
_loadProfiles(),
];
await Future.wait(futures);
selectedLanguage = availableLanguages.contains(_userL2)
? _userL2
: availableLanguages.firstOrNull;
await refresh();
if (mounted) {
setState(() => initialized = true);
}
}
Future<void> _loadProfiles() async {
await room?.requestParticipants(
[Membership.join],
false,
true,
);
final futures = _availableUsers.map((u) async {
final resp = await MatrixState.pangeaController.userController
.getPublicProfile(u.id);
_profiles[u] = resp;
if (resp.languageAnalytics == null) return;
for (final lang in resp.languageAnalytics!.entries) {
if (lang.value.analyticsRoomId == null) continue;
_langsToUsers[lang.key] ??= [];
_langsToUsers[lang.key]!.add(u);
}
});
await Future.wait(futures);
}
Future<void> refresh() async {
if (room == null || !room!.isSpace || selectedLanguage == null) return;
setState(() {
downloads = Map.fromEntries(
_availableUsers.map(
(user) {
final room = _analyticsRoomOfUser(user);
final hasLangData = _availableUsersForLang.contains(user);
RequestStatus? requestStatus;
if (room != null) {
requestStatus = RequestStatus.available;
} else if (!hasLangData) {
requestStatus = RequestStatus.unavailable;
} else {
requestStatus = AnalyticsRequestsRepo.get(
user.id,
selectedLanguage!,
) ??
RequestStatus.unrequested;
}
final DownloadStatus downloadStatus =
requestStatus == RequestStatus.available
? DownloadStatus.loading
: DownloadStatus.unavailable;
return MapEntry(
user,
AnalyticsDownload(
requestStatus: requestStatus,
downloadStatus: downloadStatus,
),
);
},
),
);
});
for (final user in _availableUsers) {
final analyticsRoom = _analyticsRoomOfUser(user);
if (analyticsRoom == null) {
continue;
}
await _setAnalyticsModel(analyticsRoom);
}
if (mounted) {
setState(() {
_lastUpdated = DateTime.now();
});
}
}
Future<void> _setAnalyticsModel(
Room analyticsRoom,
) async {
final String? userID = analyticsRoom.creatorId;
final user =
room?.getParticipants().firstWhereOrNull((p) => p.id == userID);
if (user == null) return;
SpaceAnalyticsSummaryModel? summary;
final constructEvents = await analyticsRoom.getAnalyticsEvents(
userId: userID!,
);
if (constructEvents == null) {
downloads[user] = AnalyticsDownload(
requestStatus: RequestStatus.available,
downloadStatus: DownloadStatus.complete,
summary: SpaceAnalyticsSummaryModel.emptyModel(userID),
);
} else {
final List<OneConstructUse> uses = [];
for (final event in constructEvents) {
uses.addAll(event.content.uses);
}
final constructs = ConstructListModel(uses: uses);
summary = SpaceAnalyticsSummaryModel.fromConstructListModel(
userID,
constructs,
analyticsRoom.activityRoomIds.length,
(use) =>
getGrammarCopy(
category: use.category,
lemma: use.lemma,
context: context,
) ??
use.lemma,
context,
);
downloads[user] = AnalyticsDownload(
requestStatus: RequestStatus.available,
downloadStatus: DownloadStatus.complete,
summary: summary,
);
}
if (mounted) setState(() {});
}
Future<void> _requestAnalytics(User user) async {
RequestStatus? status = downloads[user]?.requestStatus;
if (status == RequestStatus.unavailable ||
status == RequestStatus.available) {
return;
}
try {
final roomId = _analyticsRoomIdOfUser(user);
if (roomId == null) return;
await Matrix.of(context).client.knockRoom(roomId);
status = RequestStatus.requested;
} catch (e) {
status = RequestStatus.unavailable;
if (!AnalyticsRequestsRepo.getAll().any(
(status) => status == RequestStatus.unavailable,
)) {
showDialog(
context: context,
builder: (_) {
return const SpaceAnalyticsInactiveDialog();
},
);
}
} finally {
if (status != null) {
await AnalyticsRequestsRepo.set(
user.id,
selectedLanguage!,
status,
);
downloads[user]?.requestStatus = status;
}
if (mounted) setState(() {});
}
}
Future<void> requestAnalytics(User user) async {
final status = downloads[user]?.requestStatus;
if (status != RequestStatus.unrequested) return;
await showFutureLoadingDialog(
context: context,
future: () => _requestAnalytics(user),
);
}
Future<void> requestAllAnalytics() async {
final resp = await showDialog(
context: context,
builder: (_) {
return const SpaceAnalyticsRequestDialog();
},
);
if (resp != true) return;
final users = _availableUsersForLang
.where(
(user) => downloads[user]?.requestStatus == RequestStatus.unrequested,
)
.toList();
final futures = users.map((user) => _requestAnalytics(user));
await showFutureLoadingDialog(
context: context,
future: () => Future.wait(futures),
);
}
String? _analyticsRoomIdOfUser(User user) {
final profile = _profiles[user];
if (profile == null || profile.languageAnalytics == null) return null;
final entry = profile.languageAnalytics![selectedLanguage];
return entry?.analyticsRoomId;
}
Room? _analyticsRoomOfUser(User user) {
return Matrix.of(context).client.rooms.firstWhereOrNull(
(r) =>
r.isAnalyticsRoomOfUser(user.id) &&
r.madeForLang == selectedLanguage?.langCodeShort,
);
}
void setSelectedLanguage(LanguageModel? lang) {
selectedLanguage = lang;
refresh();
}
@override
Widget build(BuildContext context) => SpaceAnalyticsView(controller: this);
}

View file

@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/l10n/l10n.dart';
/// The status of requests for space analytics access.
enum RequestStatus {
/// language analytics room is not in their profile
unavailable,
/// pending response
requested,
/// there is a room in their data but it hasnt been requested
unrequested,
/// the user is in the analytics room, and doesnt need to request
available;
static RequestStatus? fromString(String value) {
switch (value) {
case 'available':
return RequestStatus.available;
case 'unrequested':
return RequestStatus.unrequested;
case 'requested':
return RequestStatus.requested;
case 'unavailable':
return RequestStatus.unavailable;
default:
return null;
}
}
IconData get icon {
switch (this) {
case RequestStatus.available:
return Icons.check_circle;
case RequestStatus.unrequested:
return Symbols.approval_delegation;
case RequestStatus.requested:
return Icons.mark_email_read_outlined;
case RequestStatus.unavailable:
return Symbols.approval_delegation;
}
}
String label(BuildContext context) {
final l10n = L10n.of(context);
switch (this) {
case RequestStatus.available:
return l10n.available;
case RequestStatus.unrequested:
return l10n.request;
case RequestStatus.requested:
return l10n.pending;
case RequestStatus.unavailable:
return l10n.noDataFound;
}
}
Color backgroundColor(BuildContext context) {
final theme = Theme.of(context);
switch (this) {
case RequestStatus.available:
case RequestStatus.unrequested:
return theme.colorScheme.primaryContainer;
case RequestStatus.unavailable:
case RequestStatus.requested:
return theme.disabledColor;
}
}
bool get showButton => this != RequestStatus.available;
bool get enabled => this == RequestStatus.unrequested;
double get opacity => this == RequestStatus.unavailable ? 0.5 : 1.0;
}
/// The status of the download process for space analytics data.
enum DownloadStatus {
/// The user is not in the analytics room, so the data cannot be downloaded.
unavailable,
/// The data is being downloaded.
loading,
/// The data has been downloaded successfully.
complete;
}

View file

@ -0,0 +1,52 @@
import 'package:flutter/material.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
class SpaceAnalyticsInactiveDialog extends StatelessWidget {
const SpaceAnalyticsInactiveDialog({
super.key,
});
@override
Widget build(BuildContext context) {
return FullWidthDialog(
maxHeight: 375.0,
maxWidth: 450.0,
dialogContent: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 24.0,
vertical: 40.0,
),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
child: Text(
L10n.of(context).analyticsInactiveTitle,
style: const TextStyle(fontSize: 24.0),
),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
Text(
L10n.of(context).analyticsInactiveDesc,
style: const TextStyle(fontSize: 16.0),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,100 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/material_symbols_icons.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
class SpaceAnalyticsRequestDialog extends StatelessWidget {
const SpaceAnalyticsRequestDialog({
super.key,
});
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
return FullWidthDialog(
maxHeight: 800.0,
maxWidth: 450.0,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
dialogContent: Stack(
children: [
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
Text(
L10n.of(context).requestAccessTitle,
style: TextStyle(fontSize: isColumnMode ? 24.0 : 20.0),
),
Text(
L10n.of(context).requestAccessDesc,
style: TextStyle(fontSize: isColumnMode ? 16.0 : 14.0),
),
CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}",
errorWidget: (context, e, s) => const SizedBox.shrink(),
progressIndicatorBuilder: (context, _, __) =>
const SizedBox.shrink(),
width: 150.0,
),
const SizedBox(height: 50.0),
],
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
padding: const EdgeInsets.all(16.0),
child: Row(
spacing: 20.0,
children: [
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(),
child: Row(
spacing: 10.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.close),
Text(L10n.of(context).cancel),
],
),
),
),
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: Row(
spacing: 10.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Symbols.approval_delegation),
Text(L10n.of(context).requestAll),
],
),
),
),
],
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,105 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:matrix/matrix.dart';
import 'package:fluffychat/config/app_config.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/analytics_page/analytics_page_constants.dart';
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
class SpaceAnalyticsRequestedDialog extends StatelessWidget {
final Room room;
const SpaceAnalyticsRequestedDialog({
super.key,
required this.room,
});
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
return FullWidthDialog(
maxHeight: 800.0,
maxWidth: 450.0,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHigh,
dialogContent: Stack(
children: [
SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
spacing: 12.0,
mainAxisSize: MainAxisSize.min,
children: [
Text(
L10n.of(context).accessRequestedTitle,
style: TextStyle(fontSize: isColumnMode ? 24.0 : 20.0),
),
Text(
L10n.of(context).accessRequestedDesc(
room.getLocalizedDisplayname(),
),
style: TextStyle(fontSize: isColumnMode ? 16.0 : 14.0),
),
CachedNetworkImage(
imageUrl:
"${AppConfig.assetsBaseURL}/${AnalyticsPageConstants.dinoBotFileName}",
errorWidget: (context, e, s) => const SizedBox.shrink(),
progressIndicatorBuilder: (context, _, __) =>
const SizedBox.shrink(),
width: 150.0,
),
const SizedBox(height: 50.0),
],
),
),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHigh,
),
padding: const EdgeInsets.all(16.0),
child: Row(
spacing: 16.0,
children: [
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
child: Row(
spacing: 10.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Symbols.approval_delegation),
Text(L10n.of(context).allowAccess),
],
),
),
),
Expanded(
child: ElevatedButton(
onPressed: () => Navigator.of(context).pop(false),
child: Row(
spacing: 10.0,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.visibility_off),
Text(L10n.of(context).denyAccess),
],
),
),
),
],
),
),
),
],
),
);
}
}

View file

@ -0,0 +1,508 @@
import 'package:flutter/material.dart';
import 'package:collection/collection.dart';
import 'package:dropdown_button2/dropdown_button2.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/l10n/l10n.dart';
import 'package:fluffychat/pangea/common/widgets/dropdown_text_button.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics.dart';
import 'package:fluffychat/pangea/space_analytics/space_analytics_download_enum.dart';
import 'package:fluffychat/pangea/spaces/widgets/download_space_analytics_dialog.dart';
import 'package:fluffychat/widgets/avatar.dart';
import 'package:fluffychat/widgets/layouts/max_width_body.dart';
class SpaceAnalyticsView extends StatelessWidget {
final SpaceAnalyticsState controller;
const SpaceAnalyticsView({
super.key,
required this.controller,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
final rowPadding = isColumnMode ? 12.0 : 4.0;
return Scaffold(
appBar: AppBar(
title: Text(L10n.of(context).spaceAnalyticsPage),
),
body: Padding(
padding: EdgeInsets.all(isColumnMode ? 16.0 : 8.0),
child: MaxWidthBody(
maxWidth: 1000,
showBorder: false,
child: Column(
spacing: isColumnMode ? 24.0 : 12.0,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
spacing: isColumnMode ? 12.0 : 4.0,
children: [
_MenuButton(
text: L10n.of(context).requestAll,
icon: Symbols.approval_delegation,
onPressed: controller.requestAllAnalytics,
),
if (controller.room != null &&
controller.availableAnalyticsRooms.isNotEmpty)
_MenuButton(
text: L10n.of(context).download,
icon: Icons.download,
onPressed: () {
showDialog(
context: context,
builder: (context) => DownloadAnalyticsDialog(
space: controller.room!,
analyticsRooms:
controller.availableAnalyticsRooms,
),
);
},
mini: !isColumnMode,
),
],
),
Row(
spacing: isColumnMode ? 12.0 : 4.0,
children: [
if (controller.lastUpdatedString != null)
Text(
L10n.of(context).lastUpdated(
controller.lastUpdatedString!,
),
textAlign: TextAlign.end,
style: TextStyle(
fontSize: isColumnMode ? 12.0 : 8.0,
color: theme.disabledColor,
),
),
_MenuButton(
text: L10n.of(context).refresh,
icon: Symbols.refresh,
onPressed: controller.refresh,
mini: !isColumnMode,
),
DropdownButtonHideUnderline(
child: DropdownButton2<LanguageModel>(
customButton: Container(
height: isColumnMode ? 36.0 : 26.0,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(40),
),
padding: EdgeInsets.symmetric(
horizontal: isColumnMode ? 8.0 : 4.0,
vertical: 4.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (controller.selectedLanguage != null)
Text(
controller.selectedLanguage!
.getDisplayName(context) ??
controller
.selectedLanguage!.displayName,
style: TextStyle(
color:
theme.colorScheme.onPrimaryContainer,
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
Icon(
Icons.arrow_drop_down,
color: theme.colorScheme.onPrimaryContainer,
size: isColumnMode ? 24.0 : 14.0,
),
],
),
),
value: controller.selectedLanguage,
items: controller.availableLanguages
.map(
(item) => DropdownMenuItem(
value: item,
child: DropdownTextButton(
text: item.getDisplayName(context) ??
item.displayName,
isSelected: false,
),
),
)
.toList(),
onChanged: controller.setSelectedLanguage,
buttonStyleData: ButtonStyleData(
// This is necessary for the ink response to match our customButton radius.
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
),
),
dropdownStyleData: const DropdownStyleData(
offset: Offset(-50, 0),
width: 150,
),
),
),
],
),
],
),
controller.initialized
? Table(
columnWidths: const {0: FlexColumnWidth(2.5)},
children: [
TableRow(
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(color: theme.dividerColor),
),
),
children: [
_TableHeaderCell(
text: L10n.of(context).viewingAnalytics(
controller.completedDownloads,
controller.downloads.length,
),
icon: Icons.group_outlined,
),
_TableHeaderCell(
text: L10n.of(context).level,
icon: Icons.star,
),
_TableHeaderCell(
text: L10n.of(context).vocab,
icon: Symbols.dictionary,
),
_TableHeaderCell(
text: L10n.of(context).grammar,
icon: Symbols.toys_and_games,
),
_TableHeaderCell(
text: L10n.of(context).activities,
icon: Icons.radar,
),
],
),
...controller.sortedDownloads.mapIndexed(
(index, entry) {
final download = entry.value;
return TableRow(
children: [
TableCell(
child: Opacity(
opacity: download.requestStatus.opacity,
child: Padding(
padding: EdgeInsets.symmetric(
vertical: rowPadding,
),
child: Row(
spacing: isColumnMode ? 16.0 : 8.0,
children: [
Avatar(
size: isColumnMode ? 64.0 : 40.0,
mxContent: entry.key.avatarUrl,
name: entry.key.calcDisplayname(),
userId: entry.key.id,
presenceUserId: entry.key.id,
),
Flexible(
child: Column(
spacing: 4.0,
mainAxisSize: MainAxisSize.min,
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
SizedBox(
height:
index == 0 ? 8.0 : 0.0,
),
Text(
entry.key.id,
maxLines: 1,
overflow:
TextOverflow.ellipsis,
style: TextStyle(
fontSize: isColumnMode
? 16.0
: 12.0,
fontWeight: FontWeight.w500,
),
),
_RequestButton(
status:
download.requestStatus,
onPressed: () => controller
.requestAnalytics(
entry.key,
),
),
const SizedBox(height: 8.0),
],
),
),
],
),
),
),
),
_TableContentCell(
text: download.summary?.level?.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
),
_TableContentCell(
text: download.summary?.numLemmas.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
),
_TableContentCell(
text: download.summary?.numMorphConstructs
.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
),
_TableContentCell(
text: download.summary?.numCompletedActivities
.toString(),
downloadStatus: download.downloadStatus,
requestStatus: download.requestStatus,
),
],
);
},
),
],
)
: const CircularProgressIndicator.adaptive(),
],
),
),
),
);
}
}
class _MenuButton extends StatelessWidget {
final String text;
final IconData icon;
final VoidCallback onPressed;
final bool mini;
const _MenuButton({
required this.text,
required this.icon,
required this.onPressed,
this.mini = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isColumnMode = FluffyThemes.isColumnMode(context);
final height = isColumnMode ? 36.0 : 26.0;
return InkWell(
borderRadius: BorderRadius.circular(40),
onTap: onPressed,
child: Container(
height: height,
width: mini ? height : null,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(40),
),
padding: EdgeInsets.symmetric(
horizontal: isColumnMode ? 8.0 : 4.0,
vertical: 4.0,
),
child: mini
? Icon(
icon,
color: theme.colorScheme.onPrimaryContainer,
size: isColumnMode ? 24.0 : 14.0,
)
: Row(
spacing: 4.0,
children: [
Icon(
icon,
color: theme.colorScheme.onPrimaryContainer,
size: isColumnMode ? 24.0 : 14.0,
),
Text(
text,
style: TextStyle(
color: theme.colorScheme.onPrimaryContainer,
fontSize: isColumnMode ? 16.0 : 12.0,
),
),
],
),
),
);
}
}
class _TableHeaderCell extends StatelessWidget {
final String text;
final IconData icon;
const _TableHeaderCell({
required this.text,
required this.icon,
});
@override
Widget build(BuildContext context) {
final isColumnMode = FluffyThemes.isColumnMode(context);
return Padding(
padding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 8.0,
),
child: Column(
spacing: 10.0,
children: [
Icon(icon, size: 22.0),
Text(
text,
style: TextStyle(
fontSize: isColumnMode ? 12.0 : 8.0,
),
),
],
),
);
}
}
class _TableContentCell extends StatelessWidget {
final String? text;
final DownloadStatus downloadStatus;
final RequestStatus requestStatus;
const _TableContentCell({
required this.text,
required this.downloadStatus,
required this.requestStatus,
});
@override
Widget build(BuildContext context) {
if (downloadStatus != DownloadStatus.complete) {
return _MissingContentCell(
downloadStatus,
requestStatus,
);
}
final isColumnMode = FluffyThemes.isColumnMode(context);
return TableCell(
verticalAlignment: TableCellVerticalAlignment.fill,
child: Opacity(
opacity: requestStatus.opacity,
child: Container(
alignment: Alignment.center,
child: Text(
text!,
style: TextStyle(
fontSize: isColumnMode ? 16.0 : 12.0,
fontWeight: FontWeight.w500,
),
),
),
),
);
}
}
class _MissingContentCell extends StatelessWidget {
final DownloadStatus status;
final RequestStatus requestStatus;
const _MissingContentCell(
this.status,
this.requestStatus,
);
@override
Widget build(BuildContext context) {
return TableCell(
verticalAlignment: TableCellVerticalAlignment.fill,
child: Opacity(
opacity: requestStatus.opacity,
child: Container(
alignment: Alignment.center,
child: status == DownloadStatus.loading
? const SizedBox(
width: 16.0,
height: 16.0,
child: CircularProgressIndicator.adaptive(),
)
: Icon(
requestStatus == RequestStatus.unavailable
? Icons.block
: Icons.visibility_off_outlined,
size: 16.0,
),
),
),
);
}
}
class _RequestButton extends StatelessWidget {
final RequestStatus status;
final VoidCallback onPressed;
const _RequestButton({
required this.status,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
if (!status.showButton) return const SizedBox.shrink();
final isColumnMode = FluffyThemes.isColumnMode(context);
return MouseRegion(
cursor: status.enabled ? SystemMouseCursors.click : MouseCursor.defer,
child: GestureDetector(
onTap: status.enabled ? onPressed : null,
child: Opacity(
opacity: status.enabled ? 0.9 : 0.3,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(40),
color: status.backgroundColor(context),
),
child: Row(
spacing: 8.0,
mainAxisSize: MainAxisSize.min,
children: [
Icon(
status.icon,
size: isColumnMode ? 12.0 : 8.0,
),
Text(
status.label(context),
style: TextStyle(fontSize: isColumnMode ? 12.0 : 8.0),
),
],
),
),
),
),
);
}
}

View file

@ -12,7 +12,6 @@ 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/bot/utils/bot_name.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';
@ -22,8 +21,10 @@ import 'package:fluffychat/widgets/matrix.dart';
class DownloadAnalyticsDialog extends StatefulWidget {
final Room space;
final List<Room> analyticsRooms;
const DownloadAnalyticsDialog({
required this.space,
required this.analyticsRooms,
super.key,
});
@ -49,26 +50,10 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
}
Future<void> _initialize() async {
try {
await widget.space.requestParticipants(
[Membership.join],
false,
true,
);
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"spaceID": widget.space.id,
},
);
} finally {
_downloadStatuses = Map.fromEntries(
_usersToDownload.map((user) => MapEntry(user.id, 0)),
);
if (mounted) setState(() => _initialized = true);
}
_downloadStatuses = Map.fromEntries(
widget.analyticsRooms.map((room) => MapEntry(room.creatorId!, 0)),
);
if (mounted) setState(() => _initialized = true);
}
DownloadType _downloadType = DownloadType.csv;
@ -83,19 +68,10 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
_downloading = false;
_downloaded = false;
_downloadStatuses = Map.fromEntries(
_usersToDownload.map((user) => MapEntry(user.id, 0)),
widget.analyticsRooms.map((room) => MapEntry(room.creatorId!, 0)),
);
}
List<User> get _usersToDownload => widget.space
.getParticipants()
.where(
(member) =>
member.id != BotName.byEnvironment &&
member.membership == Membership.join,
)
.toList();
Color _downloadStatusColor(String userID) {
final status = _downloadStatuses[userID];
if (status == 1) return Colors.yellow;
@ -122,15 +98,11 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
});
final List<SpaceAnalyticsSummaryModel> summaries = [];
await for (final batch
in widget.space.getNextAnalyticsRoomBatch(userL2!)) {
if (batch.isEmpty) continue;
final List<SpaceAnalyticsSummaryModel?> batchSummaries =
await Future.wait(
batch.map((r) => _getAnalyticsModel(r)),
);
summaries
.addAll(batchSummaries.whereType<SpaceAnalyticsSummaryModel>());
for (final room in widget.analyticsRooms) {
final summary = await _getAnalyticsModel(room);
if (summary != null) {
summaries.add(summary);
}
}
for (final userID in _downloadStatuses.keys) {
@ -152,9 +124,7 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
ErrorHandler.logError(
e: e,
s: s,
data: {
"spaceID": widget.space.id,
},
data: {},
);
_clean();
@ -209,6 +179,7 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
summary = SpaceAnalyticsSummaryModel.fromConstructListModel(
userID,
constructs,
0,
getCopy,
context,
);
@ -218,27 +189,12 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
e: e,
s: s,
data: {
"spaceID": widget.space.id,
"userID": userID,
},
);
if (mounted) setState(() => _downloadStatuses[userID] = -2);
} finally {
if (userID != widget.space.client.userID) {
try {
await analyticsRoom.leave();
} catch (e, s) {
ErrorHandler.logError(
e: e,
s: s,
data: {
"spaceID": widget.space.id,
"userID": userID,
},
);
}
}
}
return summary;
}
@ -368,14 +324,14 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
),
child: ListView.builder(
shrinkWrap: true,
itemCount: _usersToDownload.length,
itemCount: widget.analyticsRooms.length,
itemBuilder: (context, index) {
final user = _usersToDownload[index];
final userId = widget.analyticsRooms[index].creatorId;
String tooltip = "";
if (_downloadStatuses[user.id] == -1) {
if (_downloadStatuses[userId] == -1) {
tooltip = L10n.of(context).analyticsNotAvailable;
} else if (_downloadStatuses[user.id] == -2) {
} else if (_downloadStatuses[userId] == -2) {
tooltip = L10n.of(context).failedFetchUserAnalytics;
}
@ -383,14 +339,13 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
padding: const EdgeInsets.all(4.0),
child: AnimatedOpacity(
duration: FluffyThemes.animationDuration,
opacity:
(_downloadStatuses[user.id] ?? 0) > 0 ? 1 : 0.5,
opacity: (_downloadStatuses[userId] ?? 0) > 0 ? 1 : 0.5,
child: Row(
children: [
SizedBox(
width: 40,
height: 30,
child: (_downloadStatuses[user.id] ?? 0) < 0
child: (_downloadStatuses[userId] ?? 0) < 0
? const Icon(
Icons.error_outline,
size: 16,
@ -402,7 +357,7 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
height: 12,
width: 12,
decoration: BoxDecoration(
color: _downloadStatusColor(user.id),
color: _downloadStatusColor(userId!),
borderRadius:
BorderRadius.circular(100),
),
@ -413,7 +368,7 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user.displayName ?? user.id),
Text(userId!),
if (tooltip.isNotEmpty)
Text(
tooltip,
@ -473,3 +428,480 @@ class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
);
}
}
// import 'package:flutter/material.dart';
// import 'package:csv/csv.dart';
// import 'package:excel/excel.dart';
// import 'package:matrix/matrix.dart';
// import 'package:fluffychat/config/app_config.dart';
// import 'package:fluffychat/config/themes.dart';
// import 'package:fluffychat/l10n/l10n.dart';
// import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_enum.dart';
// import 'package:fluffychat/pangea/analytics_downloads/space_analytics_summary_model.dart';
// 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/bot/utils/bot_name.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/extensions/pangea_room_extension.dart';
// import 'package:fluffychat/pangea/morphs/get_grammar_copy.dart';
// import 'package:fluffychat/widgets/matrix.dart';
// class DownloadAnalyticsDialog extends StatefulWidget {
// final Room space;
// const DownloadAnalyticsDialog({
// required this.space,
// super.key,
// });
// @override
// DownloadAnalyticsDialogState createState() => DownloadAnalyticsDialogState();
// }
// class DownloadAnalyticsDialogState extends State<DownloadAnalyticsDialog> {
// bool _initialized = false;
// bool _downloaded = false;
// bool _downloading = false;
// bool get _loading => _downloading || !_initialized;
// Object? _error;
// Map<String, int> _downloadStatuses = {};
// @override
// void initState() {
// super.initState();
// _initialize();
// }
// Future<void> _initialize() async {
// try {
// await widget.space.requestParticipants(
// [Membership.join],
// false,
// true,
// );
// } catch (e, s) {
// ErrorHandler.logError(
// e: e,
// s: s,
// data: {
// "spaceID": widget.space.id,
// },
// );
// } finally {
// _downloadStatuses = Map.fromEntries(
// _usersToDownload.map((user) => MapEntry(user.id, 0)),
// );
// if (mounted) setState(() => _initialized = true);
// }
// }
// DownloadType _downloadType = DownloadType.csv;
// void _setDownloadType(DownloadType type) {
// _clean();
// if (mounted) setState(() => _downloadType = type);
// }
// void _clean() {
// _error = null;
// _downloading = false;
// _downloaded = false;
// _downloadStatuses = Map.fromEntries(
// _usersToDownload.map((user) => MapEntry(user.id, 0)),
// );
// }
// List<User> get _usersToDownload => widget.space
// .getParticipants()
// .where(
// (member) =>
// member.id != BotName.byEnvironment &&
// member.membership == Membership.join,
// )
// .toList();
// Color _downloadStatusColor(String userID) {
// final status = _downloadStatuses[userID];
// if (status == 1) return Colors.yellow;
// if (status == 2) return Colors.green;
// if ((status ?? 0) < 0) return Colors.red;
// return Colors.grey;
// }
// String? get _statusText {
// if (_downloading) return L10n.of(context).downloading;
// if (_downloaded) return L10n.of(context).downloadComplete;
// return null;
// }
// String? get userL2 =>
// MatrixState.pangeaController.languageController.userL2?.langCode;
// Future<void> _runDownload() async {
// try {
// if (!mounted || userL2 == null) return;
// setState(() {
// _error = null;
// _downloading = true;
// });
// final List<SpaceAnalyticsSummaryModel> summaries = [];
// await for (final batch
// in widget.space.getNextAnalyticsRoomBatch(userL2!)) {
// if (batch.isEmpty) continue;
// final List<SpaceAnalyticsSummaryModel?> batchSummaries =
// await Future.wait(
// batch.map((r) => _getAnalyticsModel(r)),
// );
// summaries
// .addAll(batchSummaries.whereType<SpaceAnalyticsSummaryModel>());
// }
// for (final userID in _downloadStatuses.keys) {
// if (_downloadStatuses[userID] == 0) {
// _downloadStatuses[userID] = -1;
// summaries.add(SpaceAnalyticsSummaryModel.emptyModel(userID));
// }
// }
// await _downloadSpaceAnalytics(summaries);
// if (mounted) {
// setState(() {
// _downloading = false;
// _downloaded = true;
// });
// }
// } catch (e, s) {
// ErrorHandler.logError(
// e: e,
// s: s,
// data: {
// "spaceID": widget.space.id,
// },
// );
// _clean();
// _error = e;
// if (mounted) setState(() {});
// }
// }
// Future<void> _downloadSpaceAnalytics(
// List<SpaceAnalyticsSummaryModel> summaries,
// ) async {
// final content = _downloadType == DownloadType.xlsx
// ? _getExcelFileContent(summaries)
// : _getCSVFileContent(summaries);
// final fileName =
// "analytics_${widget.space.name}_${DateTime.now().toIso8601String()}.${_downloadType == DownloadType.xlsx ? 'xlsx' : 'csv'}";
// await downloadFile(
// content,
// fileName,
// DownloadType.csv,
// );
// }
// Future<SpaceAnalyticsSummaryModel?> _getAnalyticsModel(
// Room analyticsRoom,
// ) async {
// final String? userID = analyticsRoom.creatorId;
// if (userID == null) return null;
// SpaceAnalyticsSummaryModel? summary;
// try {
// _downloadStatuses[userID] = 1;
// if (mounted) setState(() {});
// final constructEvents = await analyticsRoom.getAnalyticsEvents(
// userId: userID,
// );
// if (constructEvents == null) {
// if (mounted) setState(() => _downloadStatuses[userID] = -1);
// return SpaceAnalyticsSummaryModel.emptyModel(userID);
// }
// final List<OneConstructUse> uses = [];
// for (final event in constructEvents) {
// uses.addAll(event.content.uses);
// }
// final constructs = ConstructListModel(uses: uses);
// summary = SpaceAnalyticsSummaryModel.fromConstructListModel(
// userID,
// constructs,
// 0,
// getCopy,
// context,
// );
// if (mounted) setState(() => _downloadStatuses[userID] = 2);
// } catch (e, s) {
// ErrorHandler.logError(
// e: e,
// s: s,
// data: {
// "spaceID": widget.space.id,
// "userID": userID,
// },
// );
// if (mounted) setState(() => _downloadStatuses[userID] = -2);
// } finally {
// if (userID != widget.space.client.userID) {
// try {
// await analyticsRoom.leave();
// } catch (e, s) {
// ErrorHandler.logError(
// e: e,
// s: s,
// data: {
// "spaceID": widget.space.id,
// "userID": userID,
// },
// );
// }
// }
// }
// return summary;
// }
// List<CellValue> _formatExcelRow(
// SpaceAnalyticsSummaryModel summary,
// ) {
// final List<CellValue> row = [];
// for (int i = 0; i < SpaceAnalyticsSummaryEnum.values.length; i++) {
// final key = SpaceAnalyticsSummaryEnum.values[i];
// final value = summary.getValue(key, context);
// if (value is int) {
// row.add(IntCellValue(value));
// } else if (value is String) {
// row.add(TextCellValue(value));
// } else if (value is List<String>) {
// row.add(TextCellValue(value.join(", ")));
// }
// }
// return row;
// }
// List<int> _getExcelFileContent(
// List<SpaceAnalyticsSummaryModel> summaries,
// ) {
// final excel = Excel.createExcel();
// final sheet = excel['Sheet1'];
// for (final key in SpaceAnalyticsSummaryEnum.values) {
// sheet
// .cell(
// CellIndex.indexByColumnRow(
// rowIndex: 0,
// columnIndex: key.index,
// ),
// )
// .value = TextCellValue(key.header(L10n.of(context)));
// }
// final rows = summaries.map((summary) => _formatExcelRow(summary)).toList();
// for (int i = 0; i < rows.length; i++) {
// final row = rows[i];
// for (int j = 0; j < row.length; j++) {
// final cell = row[j];
// sheet
// .cell(CellIndex.indexByColumnRow(rowIndex: i + 2, columnIndex: j))
// .value = cell;
// }
// }
// return excel.encode() ?? [];
// }
// String _getCSVFileContent(
// List<SpaceAnalyticsSummaryModel> summaries,
// ) {
// final List<List<dynamic>> rows = [];
// final headerRow = [];
// for (final key in SpaceAnalyticsSummaryEnum.values) {
// headerRow.add(key.header(L10n.of(context)));
// }
// rows.add(headerRow);
// for (final summary in summaries) {
// final row = [];
// for (int i = 0; i < SpaceAnalyticsSummaryEnum.values.length; i++) {
// final key = SpaceAnalyticsSummaryEnum.values[i];
// final value = summary.getValue(key, context);
// if (value == null) continue;
// value is List<String> ? row.add(value.join(", ")) : row.add(value);
// }
// rows.add(row);
// }
// final String fileString = const ListToCsvConverter().convert(rows);
// return fileString;
// }
// String getCopy(ConstructUses use) {
// return getGrammarCopy(
// category: use.category,
// lemma: use.lemma,
// context: context,
// ) ??
// use.lemma;
// }
// @override
// Widget build(BuildContext context) {
// return Dialog(
// child: Container(
// constraints: const BoxConstraints(
// maxWidth: 400,
// ),
// padding: const EdgeInsets.symmetric(vertical: 20),
// child: Column(
// mainAxisSize: MainAxisSize.min,
// children: [
// Text(
// L10n.of(context).fileType,
// style: TextStyle(
// fontSize: AppConfig.fontSizeFactor * AppConfig.messageFontSize,
// ),
// ),
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: SegmentedButton<DownloadType>(
// selected: {_downloadType},
// onSelectionChanged: (c) => _setDownloadType(c.first),
// segments: [
// ButtonSegment(
// value: DownloadType.csv,
// label: Text(L10n.of(context).commaSeparatedFile),
// ),
// ButtonSegment(
// value: DownloadType.xlsx,
// label: Text(L10n.of(context).excelFile),
// ),
// ],
// ),
// ),
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: ConstrainedBox(
// constraints: const BoxConstraints(
// maxHeight: 300,
// minHeight: 0,
// ),
// child: ListView.builder(
// shrinkWrap: true,
// itemCount: _usersToDownload.length,
// itemBuilder: (context, index) {
// final user = _usersToDownload[index];
// String tooltip = "";
// if (_downloadStatuses[user.id] == -1) {
// tooltip = L10n.of(context).analyticsNotAvailable;
// } else if (_downloadStatuses[user.id] == -2) {
// tooltip = L10n.of(context).failedFetchUserAnalytics;
// }
// return Padding(
// padding: const EdgeInsets.all(4.0),
// child: AnimatedOpacity(
// duration: FluffyThemes.animationDuration,
// opacity:
// (_downloadStatuses[user.id] ?? 0) > 0 ? 1 : 0.5,
// child: Row(
// children: [
// SizedBox(
// width: 40,
// height: 30,
// child: (_downloadStatuses[user.id] ?? 0) < 0
// ? const Icon(
// Icons.error_outline,
// size: 16,
// )
// : Center(
// child: AnimatedContainer(
// duration:
// FluffyThemes.animationDuration,
// height: 12,
// width: 12,
// decoration: BoxDecoration(
// color: _downloadStatusColor(user.id),
// borderRadius:
// BorderRadius.circular(100),
// ),
// ),
// ),
// ),
// Flexible(
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(user.displayName ?? user.id),
// if (tooltip.isNotEmpty)
// Text(
// tooltip,
// style: const TextStyle(fontSize: 10),
// ),
// ],
// ),
// ),
// ],
// ),
// ),
// );
// },
// ),
// ),
// ),
// Padding(
// padding: const EdgeInsets.fromLTRB(8.0, 16.0, 8.0, 8.0),
// child: OutlinedButton(
// onPressed: _loading || !_initialized ? null : _runDownload,
// child: _initialized && !_loading
// ? Text(
// _loading
// ? L10n.of(context).downloading
// : L10n.of(context).download,
// )
// : const SizedBox(
// height: 10,
// width: 100,
// child: LinearProgressIndicator(),
// ),
// ),
// ),
// AnimatedSize(
// duration: FluffyThemes.animationDuration,
// child: _statusText != null
// ? Padding(
// padding: const EdgeInsets.all(8.0),
// child: Text(_statusText!),
// )
// : const SizedBox(),
// ),
// AnimatedSize(
// duration: FluffyThemes.animationDuration,
// child: _error != null
// ? Padding(
// padding: const EdgeInsets.all(8.0),
// child: ErrorIndicator(
// message: L10n.of(context).errorDownloading,
// ),
// )
// : const SizedBox(),
// ),
// ],
// ),
// ),
// );
// }
// }

View file

@ -4,11 +4,13 @@ import 'package:collection/collection.dart';
import 'package:jwt_decode/jwt_decode.dart';
import 'package:matrix/matrix.dart' as matrix;
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
import 'package:fluffychat/pangea/bot/utils/bot_name.dart';
import 'package:fluffychat/pangea/common/constants/model_keys.dart';
import 'package:fluffychat/pangea/common/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension.dart';
import 'package:fluffychat/pangea/learning_settings/constants/language_constants.dart';
import 'package:fluffychat/pangea/learning_settings/models/language_model.dart';
import 'package:fluffychat/pangea/learning_settings/utils/p_language_store.dart';
@ -57,7 +59,7 @@ class UserController {
StreamSubscription? _profileListener;
/// Listen for updates to account data in syncs and update the cached profile
void addProfileListener() {
void _addProfileListener() {
_profileListener ??= client.onSync.stream
.where((sync) => sync.accountData != null)
.listen((sync) {
@ -144,7 +146,9 @@ class UserController {
try {
await _initialize();
addProfileListener();
_addProfileListener();
_addAnalyticsRoomIdsToPublicProfile();
if (profile.userSettings.targetLanguage != null &&
profile.userSettings.targetLanguage!.isNotEmpty &&
_pangeaController.languageController.userL2 == null) {
@ -224,15 +228,53 @@ class UserController {
baseLanguage ??= _pangeaController.languageController.userL1;
if (targetLanguage == null || publicProfile == null) return;
final analyticsRoom =
_pangeaController.matrixState.client.analyticsRoomLocal(targetLanguage);
if (publicProfile!.targetLanguage == targetLanguage &&
publicProfile!.baseLanguage == baseLanguage &&
publicProfile!.languageAnalytics?[targetLanguage]?.level == level) {
publicProfile!.languageAnalytics?[targetLanguage]?.level == level &&
publicProfile!.analyticsRoomIdByLanguage(targetLanguage) ==
analyticsRoom?.id) {
return;
}
publicProfile!.baseLanguage = baseLanguage;
publicProfile!.targetLanguage = targetLanguage;
publicProfile!.setLevel(targetLanguage, level);
publicProfile!.setLanguageInfo(
targetLanguage,
level,
analyticsRoom?.id,
);
await _savePublicProfile();
}
Future<void> _addAnalyticsRoomIdsToPublicProfile() async {
if (publicProfile?.languageAnalytics == null) return;
final analyticsRooms =
_pangeaController.matrixState.client.allMyAnalyticsRooms;
if (analyticsRooms.isEmpty) return;
for (final analyticsRoom in analyticsRooms) {
final lang = analyticsRoom.madeForLang?.split("-").first;
if (lang == null || publicProfile?.languageAnalytics == null) continue;
final langKey = publicProfile!.languageAnalytics!.keys.firstWhereOrNull(
(l) => l.langCodeShort == lang,
);
if (langKey == null) continue;
if (publicProfile!.languageAnalytics![langKey]!.analyticsRoomId ==
analyticsRoom.id) {
continue;
}
publicProfile!.setLanguageInfo(
langKey,
publicProfile!.languageAnalytics![langKey]!.level,
analyticsRoom.id,
);
}
await _savePublicProfile();
}
@ -240,7 +282,13 @@ class UserController {
final targetLanguage = _pangeaController.languageController.userL2;
if (targetLanguage == null || publicProfile == null) return;
publicProfile!.addXPOffset(targetLanguage, offset);
publicProfile!.addXPOffset(
targetLanguage,
offset,
_pangeaController.matrixState.client
.analyticsRoomLocal(targetLanguage)
?.id,
);
await _savePublicProfile();
}

View file

@ -37,8 +37,13 @@ class PublicProfileModel {
if (lang == null) continue;
final level = entry.value[ModelKey.level];
final xpOffset = entry.value[ModelKey.xpOffset] ?? 0;
languageAnalytics[lang] =
LanguageAnalyticsProfileEntry(level, xpOffset);
final analyticsRoomId =
entry.value[ModelKey.analyticsRoomId] as String?;
languageAnalytics[lang] = LanguageAnalyticsProfileEntry(
level,
xpOffset,
analyticsRoomId: analyticsRoomId,
);
}
}
@ -67,6 +72,8 @@ class PublicProfileModel {
analytics[entry.key.langCode] = {
ModelKey.level: entry.value.level,
ModelKey.xpOffset: entry.value.xpOffset,
if (entry.value.analyticsRoomId != null)
ModelKey.analyticsRoomId: entry.value.analyticsRoomId,
};
}
}
@ -80,15 +87,49 @@ class PublicProfileModel {
targetLanguage == null ||
(languageAnalytics == null || languageAnalytics!.isEmpty);
void setLevel(LanguageModel language, int level) {
String? analyticsRoomIdByLanguage(LanguageModel language) =>
languageAnalytics![language]?.analyticsRoomId;
/// Set the level and analytics room ID for the a given language.
void setLanguageInfo(
LanguageModel language,
int level,
String? analyticsRoomId,
) {
languageAnalytics ??= {};
languageAnalytics![language] ??= LanguageAnalyticsProfileEntry(0, 0);
languageAnalytics![language] ??= LanguageAnalyticsProfileEntry(
0,
0,
analyticsRoomId: analyticsRoomId,
);
if (languageAnalytics![language]!.level < level) {
languageAnalytics![language]!.level = level;
}
final currentRoomId = analyticsRoomIdByLanguage(language);
if (currentRoomId == null) {
languageAnalytics![language]!.analyticsRoomId = analyticsRoomId;
}
languageAnalytics![language]!.level = level;
}
void addXPOffset(LanguageModel language, int xpOffset) {
void addXPOffset(
LanguageModel language,
int xpOffset,
String? analyticsRoomId,
) {
languageAnalytics ??= {};
languageAnalytics![language] ??= LanguageAnalyticsProfileEntry(0, 0);
languageAnalytics![language] ??= LanguageAnalyticsProfileEntry(
0,
0,
analyticsRoomId: analyticsRoomId,
);
final currentRoomId = analyticsRoomIdByLanguage(language);
if (currentRoomId == null) {
languageAnalytics![language]!.analyticsRoomId = analyticsRoomId;
}
languageAnalytics![language]!.xpOffset += xpOffset;
}
@ -100,6 +141,11 @@ class PublicProfileModel {
class LanguageAnalyticsProfileEntry {
int level;
int xpOffset = 0;
String? analyticsRoomId;
LanguageAnalyticsProfileEntry(this.level, this.xpOffset);
LanguageAnalyticsProfileEntry(
this.level,
this.xpOffset, {
this.analyticsRoomId,
});
}