5719 level up issues (#5750)
* fix: ensure accuracy of XP before level to fix offset issue * chore: remove level-up summaries
This commit is contained in:
parent
13700d9a9a
commit
c9d70ab5d8
11 changed files with 82 additions and 1082 deletions
|
|
@ -10,7 +10,6 @@ import 'package:fluffychat/pangea/analytics_data/analytics_update_events.dart';
|
|||
import 'package:fluffychat/pangea/analytics_data/analytics_update_service.dart';
|
||||
import 'package:fluffychat/pangea/analytics_data/construct_merge_table.dart';
|
||||
import 'package:fluffychat/pangea/analytics_data/derived_analytics_data_model.dart';
|
||||
import 'package:fluffychat/pangea/analytics_data/level_up_analytics_service.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
|
|
@ -49,7 +48,6 @@ class AnalyticsDataService {
|
|||
|
||||
late final AnalyticsUpdateDispatcher updateDispatcher;
|
||||
late final AnalyticsUpdateService updateService;
|
||||
late final LevelUpAnalyticsService levelUpService;
|
||||
AnalyticsSyncController? _syncController;
|
||||
final ConstructMergeTable _mergeTable = ConstructMergeTable();
|
||||
|
||||
|
|
@ -58,11 +56,6 @@ class AnalyticsDataService {
|
|||
AnalyticsDataService(Client client) {
|
||||
updateDispatcher = AnalyticsUpdateDispatcher(this);
|
||||
updateService = AnalyticsUpdateService(this);
|
||||
levelUpService = LevelUpAnalyticsService(
|
||||
client: client,
|
||||
ensureInitialized: () => _ensureInitialized(),
|
||||
dataService: this,
|
||||
);
|
||||
_initDatabase(client);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -41,8 +41,7 @@ class DerivedAnalyticsDataModel {
|
|||
// XP from the inverse formula:
|
||||
final double xpDouble = (D / 8.0) * (2.0 * pow(lc - 1.0, 2.0) - 1.0);
|
||||
|
||||
// Floor or clamp to ensure non-negative.
|
||||
final int xp = xpDouble.floor();
|
||||
final int xp = xpDouble.ceil();
|
||||
return (xp < 0) ? 0 : xp;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,122 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
|
||||
import 'package:fluffychat/pangea/events/event_wrappers/pangea_message_event.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class LevelUpAnalyticsService {
|
||||
final Client client;
|
||||
final Future<void> Function() ensureInitialized;
|
||||
final AnalyticsDataService dataService;
|
||||
|
||||
const LevelUpAnalyticsService({
|
||||
required this.client,
|
||||
required this.ensureInitialized,
|
||||
required this.dataService,
|
||||
});
|
||||
|
||||
Future<ConstructSummary> getLevelUpAnalytics(
|
||||
int lowerLevel,
|
||||
int upperLevel,
|
||||
DateTime? lastLevelUpTimestamp,
|
||||
) async {
|
||||
await ensureInitialized();
|
||||
|
||||
final userController = MatrixState.pangeaController.userController;
|
||||
final l2 = userController.userL2;
|
||||
if (l2 == null) {
|
||||
throw Exception("No L2 language set for user");
|
||||
}
|
||||
|
||||
final uses = await dataService.getUses(
|
||||
l2.langCodeShort,
|
||||
since: lastLevelUpTimestamp,
|
||||
);
|
||||
final messages = await _buildMessageContext(uses);
|
||||
|
||||
final request = ConstructSummaryRequest(
|
||||
constructs: uses,
|
||||
messages: messages,
|
||||
userL1: userController.userL1!.langCodeShort,
|
||||
userL2: userController.userL2!.langCodeShort,
|
||||
lowerLevel: lowerLevel,
|
||||
upperLevel: upperLevel,
|
||||
);
|
||||
|
||||
final response = await ConstructRepo.generateConstructSummary(request);
|
||||
final summary = response.summary;
|
||||
|
||||
summary.levelVocabConstructs = dataService.uniqueConstructsByType(
|
||||
ConstructTypeEnum.vocab,
|
||||
);
|
||||
summary.levelGrammarConstructs = dataService.uniqueConstructsByType(
|
||||
ConstructTypeEnum.morph,
|
||||
);
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _buildMessageContext(
|
||||
List<OneConstructUse> uses,
|
||||
) async {
|
||||
final Map<String, Set<String>> useEventIds = {};
|
||||
|
||||
for (final use in uses) {
|
||||
final roomId = use.metadata.roomId;
|
||||
final eventId = use.metadata.eventId;
|
||||
if (roomId == null || eventId == null) continue;
|
||||
|
||||
useEventIds.putIfAbsent(roomId, () => {}).add(eventId);
|
||||
}
|
||||
|
||||
final List<Map<String, dynamic>> messages = [];
|
||||
|
||||
for (final entry in useEventIds.entries) {
|
||||
final room = client.getRoomById(entry.key);
|
||||
if (room == null) continue;
|
||||
|
||||
final timeline = await room.getTimeline();
|
||||
|
||||
for (final eventId in entry.value) {
|
||||
try {
|
||||
final event = await room.getEventById(eventId);
|
||||
if (event == null) continue;
|
||||
|
||||
final pangeaEvent = PangeaMessageEvent(
|
||||
event: event,
|
||||
timeline: timeline,
|
||||
ownMessage: room.client.userID == event.senderId,
|
||||
);
|
||||
|
||||
if (pangeaEvent.isAudioMessage) {
|
||||
final stt = pangeaEvent.getSpeechToTextLocal();
|
||||
if (stt == null) continue;
|
||||
messages.add({
|
||||
'sent': stt.transcript.text,
|
||||
'written': stt.transcript.text,
|
||||
});
|
||||
} else {
|
||||
messages.add({
|
||||
'sent': pangeaEvent.originalSent?.text ?? pangeaEvent.body,
|
||||
'written': pangeaEvent.originalWrittenContent,
|
||||
});
|
||||
}
|
||||
} catch (e, s) {
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {'roomId': entry.key, 'eventId': eventId},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
|
||||
import 'package:fluffychat/pangea/events/constants/pangea_event_types.dart';
|
||||
|
||||
extension LevelSummaryExtension on Room {
|
||||
ConstructSummary? get levelUpSummary {
|
||||
final summaryEvent = getState(PangeaEventTypes.constructSummary);
|
||||
if (summaryEvent != null) {
|
||||
return ConstructSummary.fromJson(summaryEvent.content);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
DateTime? get lastLevelUpTimestamp {
|
||||
final lastLevelUp = getState(PangeaEventTypes.constructSummary);
|
||||
return lastLevelUp is Event ? lastLevelUp.originServerTs : null;
|
||||
}
|
||||
|
||||
Future<void> setLevelUpSummary(ConstructSummary summary) =>
|
||||
client.setRoomStateWithKey(
|
||||
id,
|
||||
PangeaEventTypes.constructSummary,
|
||||
'',
|
||||
summary.toJson(),
|
||||
);
|
||||
}
|
||||
|
|
@ -10,13 +10,7 @@ import 'package:fluffychat/config/setting_keys.dart';
|
|||
import 'package:fluffychat/config/themes.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/analytics_constants.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_summary_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_popup.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/error_handler.dart';
|
||||
import 'package:fluffychat/pangea/common/utils/overlay.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class LevelUpConstants {
|
||||
|
|
@ -77,7 +71,7 @@ class LevelUpUtil {
|
|||
}
|
||||
}
|
||||
|
||||
class LevelUpBanner extends StatefulWidget {
|
||||
class LevelUpBanner extends StatelessWidget {
|
||||
final int level;
|
||||
final int prevLevel;
|
||||
final Widget? backButtonOverride;
|
||||
|
|
@ -89,103 +83,6 @@ class LevelUpBanner extends StatefulWidget {
|
|||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
LevelUpBannerState createState() => LevelUpBannerState();
|
||||
}
|
||||
|
||||
class LevelUpBannerState extends State<LevelUpBanner>
|
||||
with TickerProviderStateMixin {
|
||||
late AnimationController _slideController;
|
||||
late Animation<Offset> _slideAnimation;
|
||||
|
||||
bool _showedDetails = false;
|
||||
|
||||
final Completer<ConstructSummary> _constructSummaryCompleter =
|
||||
Completer<ConstructSummary>();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
_loadConstructSummary();
|
||||
|
||||
final analyticsService = Matrix.of(context).analyticsDataService;
|
||||
LevelUpManager.instance.preloadAnalytics(
|
||||
widget.level,
|
||||
widget.prevLevel,
|
||||
analyticsService,
|
||||
);
|
||||
|
||||
_slideController = AnimationController(
|
||||
vsync: this,
|
||||
duration: FluffyThemes.animationDuration,
|
||||
);
|
||||
|
||||
_slideAnimation = Tween<Offset>(
|
||||
begin: const Offset(0, -1),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOut));
|
||||
|
||||
_slideController.forward();
|
||||
|
||||
Future.delayed(const Duration(seconds: 10), () async {
|
||||
if (mounted && !_showedDetails) {
|
||||
_close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _close() async {
|
||||
await _slideController.reverse();
|
||||
MatrixState.pAnyState.closeOverlay("level_up_notification");
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_slideController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _toggleDetails() async {
|
||||
await _close();
|
||||
LevelUpManager.instance.markPopupSeen();
|
||||
_showedDetails = true;
|
||||
|
||||
FocusScope.of(context).unfocus();
|
||||
|
||||
await showDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
LevelUpPopup(constructSummaryCompleter: _constructSummaryCompleter),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _loadConstructSummary() async {
|
||||
try {
|
||||
final analyticsRoom = await Matrix.of(context).client.getMyAnalyticsRoom(
|
||||
MatrixState.pangeaController.userController.userL2!,
|
||||
);
|
||||
|
||||
final timestamp = analyticsRoom!.lastLevelUpTimestamp;
|
||||
final analyticsService = Matrix.of(context).analyticsDataService;
|
||||
final summary = await analyticsService.levelUpService.getLevelUpAnalytics(
|
||||
widget.prevLevel,
|
||||
widget.level,
|
||||
timestamp,
|
||||
);
|
||||
_constructSummaryCompleter.complete(summary);
|
||||
analyticsRoom.setLevelUpSummary(summary);
|
||||
} catch (e, s) {
|
||||
debugPrint("Error generating level up analytics: $e");
|
||||
ErrorHandler.logError(
|
||||
e: e,
|
||||
s: s,
|
||||
data: {"level": widget.level, "prevLevel": widget.prevLevel},
|
||||
);
|
||||
_constructSummaryCompleter.completeError(e);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isColumnMode = FluffyThemes.isColumnMode(context);
|
||||
|
|
@ -205,101 +102,88 @@ class LevelUpBannerState extends State<LevelUpBanner>
|
|||
return SafeArea(
|
||||
child: Material(
|
||||
type: MaterialType.transparency,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return GestureDetector(
|
||||
onPanUpdate: (details) {
|
||||
if (details.delta.dy < -10) _close();
|
||||
},
|
||||
onTap: _toggleDetails,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0,
|
||||
horizontal: 4.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppConfig.gold.withAlpha(200),
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(AppConfig.borderRadius),
|
||||
bottomRight: Radius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Spacer for symmetry
|
||||
SizedBox(
|
||||
width: constraints.maxWidth >= 600 ? 120.0 : 65.0,
|
||||
),
|
||||
// Centered content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isColumnMode ? 16.0 : 8.0,
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 16.0,
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).levelUp,
|
||||
style: style,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
CachedNetworkImage(
|
||||
imageUrl:
|
||||
"${AppConfig.assetsBaseURL}/${LevelUpConstants.starFileName}",
|
||||
height: 24,
|
||||
width: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: constraints.maxWidth >= 600 ? 120.0 : 65.0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: Center(
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
),
|
||||
onPressed: () {
|
||||
MatrixState.pAnyState.closeOverlay(
|
||||
"level_up_notification",
|
||||
);
|
||||
},
|
||||
constraints: const BoxConstraints(),
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0,
|
||||
horizontal: 4.0,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: AppConfig.gold.withAlpha(200),
|
||||
width: 2.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(AppConfig.borderRadius),
|
||||
bottomRight: Radius.circular(AppConfig.borderRadius),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Spacer for symmetry
|
||||
SizedBox(width: constraints.maxWidth >= 600 ? 120.0 : 65.0),
|
||||
// Centered content
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isColumnMode ? 16.0 : 8.0,
|
||||
),
|
||||
child: Wrap(
|
||||
spacing: 16.0,
|
||||
alignment: WrapAlignment.center,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
L10n.of(context).levelUp,
|
||||
style: style,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
CachedNetworkImage(
|
||||
imageUrl:
|
||||
"${AppConfig.assetsBaseURL}/${LevelUpConstants.starFileName}",
|
||||
height: 24,
|
||||
width: 24,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
width: constraints.maxWidth >= 600 ? 120.0 : 65.0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 32.0,
|
||||
height: 32.0,
|
||||
child: Center(
|
||||
child: IconButton(
|
||||
icon: const Icon(Icons.close),
|
||||
style: IconButton.styleFrom(
|
||||
padding: const EdgeInsets.all(4.0),
|
||||
),
|
||||
onPressed: () {
|
||||
MatrixState.pAnyState.closeOverlay(
|
||||
"level_up_notification",
|
||||
);
|
||||
},
|
||||
constraints: const BoxConstraints(),
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,77 +0,0 @@
|
|||
import 'package:matrix/matrix.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_data/analytics_data_service.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/client_analytics_extension.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/construct_type_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_summary_extension.dart';
|
||||
import 'package:fluffychat/pangea/languages/language_model.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class LevelUpManager {
|
||||
// Singleton instance so analytics can be generated when level up is initiated, and be ready by the time user clicks on banner
|
||||
static final LevelUpManager instance = LevelUpManager._internal();
|
||||
|
||||
LevelUpManager._internal();
|
||||
|
||||
int prevLevel = 0;
|
||||
int level = 0;
|
||||
|
||||
int prevGrammar = 0;
|
||||
int nextGrammar = 0;
|
||||
int prevVocab = 0;
|
||||
int nextVocab = 0;
|
||||
|
||||
bool hasSeenPopup = false;
|
||||
bool shouldAutoPopup = false;
|
||||
|
||||
Future<void> preloadAnalytics(
|
||||
int level,
|
||||
int prevLevel,
|
||||
AnalyticsDataService analyticsService,
|
||||
) async {
|
||||
this.level = level;
|
||||
this.prevLevel = prevLevel;
|
||||
|
||||
//For on route change behavior, if added in the future
|
||||
shouldAutoPopup = true;
|
||||
|
||||
nextGrammar = analyticsService.numConstructs(ConstructTypeEnum.morph);
|
||||
nextVocab = analyticsService.numConstructs(ConstructTypeEnum.vocab);
|
||||
|
||||
final LanguageModel? l2 =
|
||||
MatrixState.pangeaController.userController.userL2;
|
||||
final Room? analyticsRoom = MatrixState.pangeaController.matrixState.client
|
||||
.analyticsRoomLocal(l2!);
|
||||
|
||||
if (analyticsRoom != null) {
|
||||
final lastSummary = analyticsRoom.levelUpSummary;
|
||||
|
||||
//Set grammar and vocab from last level summary, if there is one. Otherwise set to placeholder data
|
||||
if (lastSummary != null &&
|
||||
lastSummary.levelVocabConstructs != null &&
|
||||
lastSummary.levelGrammarConstructs != null) {
|
||||
prevVocab = lastSummary.levelVocabConstructs!;
|
||||
prevGrammar = lastSummary.levelGrammarConstructs!;
|
||||
} else {
|
||||
prevGrammar = nextGrammar - (nextGrammar / prevLevel).round();
|
||||
prevVocab = nextVocab - (nextVocab / prevLevel).round();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void markPopupSeen() {
|
||||
hasSeenPopup = true;
|
||||
shouldAutoPopup = false;
|
||||
}
|
||||
|
||||
void reset() {
|
||||
hasSeenPopup = false;
|
||||
shouldAutoPopup = false;
|
||||
prevLevel = 0;
|
||||
level = 0;
|
||||
prevGrammar = 0;
|
||||
nextGrammar = 0;
|
||||
prevVocab = 0;
|
||||
nextVocab = 0;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,517 +0,0 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:animated_flip_counter/animated_flip_counter.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:matrix/matrix_api_lite/generated/model.dart';
|
||||
|
||||
import 'package:fluffychat/config/app_config.dart';
|
||||
import 'package:fluffychat/l10n/l10n.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/learning_skills_enum.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_up/level_popup_progess_bar.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_banner.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_up/level_up_manager.dart';
|
||||
import 'package:fluffychat/pangea/analytics_misc/level_up/star_rain_widget.dart';
|
||||
import 'package:fluffychat/pangea/common/widgets/error_indicator.dart';
|
||||
import 'package:fluffychat/pangea/common/widgets/full_width_dialog.dart';
|
||||
import 'package:fluffychat/pangea/constructs/construct_repo.dart';
|
||||
import 'package:fluffychat/pangea/languages/language_constants.dart';
|
||||
import 'package:fluffychat/utils/localized_exception_extension.dart';
|
||||
import 'package:fluffychat/widgets/avatar.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
import 'package:fluffychat/widgets/mxc_image.dart';
|
||||
|
||||
class LevelUpPopup extends StatefulWidget {
|
||||
final Completer<ConstructSummary> constructSummaryCompleter;
|
||||
const LevelUpPopup({required this.constructSummaryCompleter, super.key});
|
||||
|
||||
@override
|
||||
State<LevelUpPopup> createState() => _LevelUpPopupState();
|
||||
}
|
||||
|
||||
class _LevelUpPopupState extends State<LevelUpPopup> {
|
||||
bool shouldShowRain = false;
|
||||
|
||||
void setShowRain(bool show) {
|
||||
setState(() {
|
||||
shouldShowRain = show;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
FullWidthDialog(
|
||||
maxWidth: 400,
|
||||
maxHeight: 800,
|
||||
dialogContent: Scaffold(
|
||||
appBar: AppBar(
|
||||
centerTitle: true,
|
||||
title: kIsWeb
|
||||
? Text(
|
||||
L10n.of(context).youHaveLeveledUp,
|
||||
style: const TextStyle(
|
||||
color: AppConfig.gold,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
body: LevelUpPopupContent(
|
||||
prevLevel: LevelUpManager.instance.prevLevel,
|
||||
level: LevelUpManager.instance.level,
|
||||
constructSummaryCompleter: widget.constructSummaryCompleter,
|
||||
onRainTrigger: () => setShowRain(true),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (shouldShowRain) const StarRainWidget(showBlast: true),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LevelUpPopupContent extends StatefulWidget {
|
||||
final int prevLevel;
|
||||
final int level;
|
||||
final Completer<ConstructSummary> constructSummaryCompleter;
|
||||
|
||||
final VoidCallback? onRainTrigger;
|
||||
|
||||
const LevelUpPopupContent({
|
||||
super.key,
|
||||
required this.prevLevel,
|
||||
required this.level,
|
||||
required this.constructSummaryCompleter,
|
||||
this.onRainTrigger,
|
||||
});
|
||||
|
||||
@override
|
||||
State<LevelUpPopupContent> createState() => _LevelUpPopupContentState();
|
||||
}
|
||||
|
||||
class _LevelUpPopupContentState extends State<LevelUpPopupContent>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
late final Future<Profile> profile;
|
||||
|
||||
int displayedLevel = -1;
|
||||
Uri? avatarUrl;
|
||||
final bool _hasBlastedConfetti = false;
|
||||
|
||||
String language =
|
||||
MatrixState.pangeaController.userController.userL2Code?.toUpperCase() ??
|
||||
LanguageKeys.unknownLanguage;
|
||||
|
||||
ConstructSummary? _constructSummary;
|
||||
Object? _error;
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadConstructSummary();
|
||||
LevelUpManager.instance.markPopupSeen();
|
||||
displayedLevel = widget.prevLevel;
|
||||
|
||||
final client = Matrix.of(context).client;
|
||||
client.fetchOwnProfile().then((profile) {
|
||||
setState(() => avatarUrl = profile.avatarUrl);
|
||||
});
|
||||
|
||||
_controller = AnimationController(
|
||||
duration: const Duration(seconds: 5),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
// halfway through the animation, switch to the new level
|
||||
_controller.addListener(() {
|
||||
if (_controller.value >= 0.5 && displayedLevel == widget.prevLevel) {
|
||||
setState(() {
|
||||
displayedLevel = widget.level;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Listener to trigger rain confetti via callback
|
||||
_controller.addListener(() {
|
||||
if (_controller.value >= 0.5 && !_hasBlastedConfetti) {
|
||||
// _hasBlastedConfetti = true;
|
||||
if (widget.onRainTrigger != null) widget.onRainTrigger!();
|
||||
}
|
||||
});
|
||||
|
||||
_controller.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
LevelUpManager.instance.reset();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
int get _startGrammar => LevelUpManager.instance.prevGrammar;
|
||||
int get _startVocab => LevelUpManager.instance.prevVocab;
|
||||
|
||||
int get _endGrammar => LevelUpManager.instance.nextGrammar;
|
||||
int get _endVocab => LevelUpManager.instance.nextVocab;
|
||||
|
||||
Future<void> _loadConstructSummary() async {
|
||||
try {
|
||||
_constructSummary = await widget.constructSummaryCompleter.future;
|
||||
} catch (e) {
|
||||
_error = e;
|
||||
} finally {
|
||||
setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
int _getSkillXP(LearningSkillsEnum skill) {
|
||||
if (_constructSummary == null) return 0;
|
||||
return switch (skill) {
|
||||
LearningSkillsEnum.writing =>
|
||||
_constructSummary?.writingConstructScore ?? 0,
|
||||
LearningSkillsEnum.reading =>
|
||||
_constructSummary?.readingConstructScore ?? 0,
|
||||
LearningSkillsEnum.speaking =>
|
||||
_constructSummary?.speakingConstructScore ?? 0,
|
||||
LearningSkillsEnum.hearing =>
|
||||
_constructSummary?.hearingConstructScore ?? 0,
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Animation<int> vocabAnimation =
|
||||
IntTween(begin: _startVocab, end: _endVocab).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeInOutQuad),
|
||||
),
|
||||
);
|
||||
|
||||
final Animation<int> grammarAnimation =
|
||||
IntTween(begin: _startGrammar, end: _endGrammar).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeInOutQuad),
|
||||
),
|
||||
);
|
||||
|
||||
final Animation<double> skillsOpacity = Tween<double>(begin: 0.0, end: 1.0)
|
||||
.animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.easeIn),
|
||||
),
|
||||
);
|
||||
|
||||
final Animation<double> shrinkMultiplier =
|
||||
Tween<double>(begin: 1.0, end: 0.3).animate(
|
||||
CurvedAnimation(
|
||||
parent: _controller,
|
||||
curve: const Interval(0.7, 1.0, curve: Curves.easeInOut),
|
||||
),
|
||||
);
|
||||
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final grammarVocabStyle = Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: colorScheme.primary,
|
||||
);
|
||||
final username =
|
||||
Matrix.of(context).client.userID?.split(':').first.substring(1) ?? '';
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, _) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: avatarUrl == null
|
||||
? Avatar(
|
||||
name: username,
|
||||
showPresence: false,
|
||||
size: 150 * shrinkMultiplier.value,
|
||||
)
|
||||
: ClipOval(
|
||||
child: MxcImage(
|
||||
uri: avatarUrl,
|
||||
width: 150 * shrinkMultiplier.value,
|
||||
height: 150 * shrinkMultiplier.value,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
language,
|
||||
style: TextStyle(
|
||||
fontSize: 24 * skillsOpacity.value,
|
||||
color: AppConfig.goldLight,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Progress bar + Level
|
||||
AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, _) => Row(
|
||||
children: [
|
||||
const Expanded(
|
||||
child: LevelPopupProgressBar(
|
||||
height: 20,
|
||||
duration: Duration(milliseconds: 1000),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text("⭐", style: Theme.of(context).textTheme.titleLarge),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: AnimatedFlipCounter(
|
||||
value: displayedLevel,
|
||||
textStyle: Theme.of(context).textTheme.headlineMedium
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppConfig.goldLight,
|
||||
),
|
||||
duration: const Duration(milliseconds: 1000),
|
||||
curve: Curves.easeInOut,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
|
||||
// Vocab and grammar row
|
||||
AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (_, _) => Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
"+ ${_endVocab - _startVocab}",
|
||||
style: const TextStyle(
|
||||
color: Colors.lightGreen,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.dictionary,
|
||||
color: colorScheme.primary,
|
||||
size: 35,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${vocabAnimation.value}',
|
||||
style: grammarVocabStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 40),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
"+ ${_endGrammar - _startGrammar}",
|
||||
style: const TextStyle(
|
||||
color: Colors.lightGreen,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.toys_and_games,
|
||||
color: colorScheme.primary,
|
||||
size: 35,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${grammarAnimation.value}',
|
||||
style: grammarVocabStyle,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_loading)
|
||||
const Center(
|
||||
child: SizedBox(
|
||||
height: 50,
|
||||
width: 50,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.0,
|
||||
color: AppConfig.goldLight,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (_error != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: ErrorIndicator(
|
||||
message: _error!.toLocalizedString(context),
|
||||
),
|
||||
)
|
||||
else if (_constructSummary != null)
|
||||
// Skills section
|
||||
AnimatedBuilder(
|
||||
animation: skillsOpacity,
|
||||
builder: (_, _) => Opacity(
|
||||
opacity: skillsOpacity.value,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
_buildSkillsTable(context),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
_constructSummary!.textSummary,
|
||||
textAlign: TextAlign.left,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl:
|
||||
"${AppConfig.assetsBaseURL}/${LevelUpConstants.dinoLevelUPFileName}",
|
||||
width: 400,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// Share button, currently no functionality
|
||||
// ElevatedButton(
|
||||
// onPressed: () {
|
||||
// // Add share functionality
|
||||
// },
|
||||
// style: ElevatedButton.styleFrom(
|
||||
// backgroundColor: Colors.white,
|
||||
// foregroundColor: Colors.black,
|
||||
// padding: const EdgeInsets.symmetric(
|
||||
// vertical: 12,
|
||||
// horizontal: 24,
|
||||
// ),
|
||||
// shape: RoundedRectangleBorder(
|
||||
// borderRadius: BorderRadius.circular(8),
|
||||
// ),
|
||||
// ),
|
||||
// child: const Row(
|
||||
// mainAxisSize: MainAxisSize.min,
|
||||
// children: [
|
||||
// Text(
|
||||
// "Share with Friends",
|
||||
// style: TextStyle(
|
||||
// fontSize: 16,
|
||||
// fontWeight: FontWeight.bold,
|
||||
// ),
|
||||
// ),
|
||||
// SizedBox(
|
||||
// width: 8,
|
||||
// ),
|
||||
// Icon(
|
||||
// Icons.ios_share,
|
||||
// size: 20,
|
||||
// ),
|
||||
// ],
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkillsTable(BuildContext context) {
|
||||
final visibleSkills = LearningSkillsEnum.values
|
||||
.where((skill) => (_getSkillXP(skill) > -1) && skill.isVisible)
|
||||
.toList();
|
||||
|
||||
const itemsPerRow = 4;
|
||||
// chunk into rows of up to 4
|
||||
final rows = <List<LearningSkillsEnum>>[
|
||||
for (var i = 0; i < visibleSkills.length; i += itemsPerRow)
|
||||
visibleSkills.sublist(i, min(i + itemsPerRow, visibleSkills.length)),
|
||||
];
|
||||
|
||||
return Column(
|
||||
children: rows.map((row) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: row.map((skill) {
|
||||
return Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
skill.tooltip(context),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Icon(
|
||||
skill.icon,
|
||||
size: 25,
|
||||
color: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'+ ${_getSkillXP(skill)} XP',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: AppConfig.gold,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,8 +74,6 @@ class PApiUrls {
|
|||
"${PApiUrls._choreoEndpoint}/token/feedback_v2";
|
||||
|
||||
static String morphFeaturesAndTags = "${PApiUrls._choreoEndpoint}/morphs";
|
||||
static String constructSummary =
|
||||
"${PApiUrls._choreoEndpoint}/construct_summary";
|
||||
|
||||
///--------------------------- course translations ---------------------------
|
||||
static String getLocalizedCourse =
|
||||
|
|
|
|||
|
|
@ -1,129 +0,0 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import 'package:fluffychat/pangea/analytics_misc/constructs_model.dart';
|
||||
import 'package:fluffychat/pangea/common/config/environment.dart';
|
||||
import 'package:fluffychat/pangea/common/network/requests.dart';
|
||||
import 'package:fluffychat/pangea/common/network/urls.dart';
|
||||
import 'package:fluffychat/widgets/matrix.dart';
|
||||
|
||||
class ConstructSummary {
|
||||
final int upperLevel;
|
||||
final int lowerLevel;
|
||||
int? levelVocabConstructs;
|
||||
int? levelGrammarConstructs;
|
||||
final String language;
|
||||
final String textSummary;
|
||||
final int writingConstructScore;
|
||||
final int readingConstructScore;
|
||||
final int hearingConstructScore;
|
||||
final int speakingConstructScore;
|
||||
|
||||
ConstructSummary({
|
||||
required this.upperLevel,
|
||||
required this.lowerLevel,
|
||||
this.levelVocabConstructs,
|
||||
this.levelGrammarConstructs,
|
||||
required this.language,
|
||||
required this.textSummary,
|
||||
required this.writingConstructScore,
|
||||
required this.readingConstructScore,
|
||||
required this.hearingConstructScore,
|
||||
required this.speakingConstructScore,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'upper_level': upperLevel,
|
||||
'lower_level': lowerLevel,
|
||||
'level_grammar_constructs': levelGrammarConstructs,
|
||||
'level_vocab_constructs': levelVocabConstructs,
|
||||
'language': language,
|
||||
'text_summary': textSummary,
|
||||
'writing_construct_score': writingConstructScore,
|
||||
'reading_construct_score': readingConstructScore,
|
||||
'hearing_construct_score': hearingConstructScore,
|
||||
'speaking_construct_score': speakingConstructScore,
|
||||
};
|
||||
}
|
||||
|
||||
factory ConstructSummary.fromJson(Map<String, dynamic> json) {
|
||||
return ConstructSummary(
|
||||
upperLevel: json['upper_level'],
|
||||
lowerLevel: json['lower_level'],
|
||||
levelGrammarConstructs: json['level_grammar_constructs'],
|
||||
levelVocabConstructs: json['level_vocab_constructs'],
|
||||
language: json['language'],
|
||||
textSummary: json['text_summary'],
|
||||
writingConstructScore: json['writing_construct_score'],
|
||||
readingConstructScore: json['reading_construct_score'],
|
||||
hearingConstructScore: json['hearing_construct_score'],
|
||||
speakingConstructScore: json['speaking_construct_score'],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConstructSummaryRequest {
|
||||
final List<OneConstructUse> constructs;
|
||||
final List<Map<String, dynamic>> messages;
|
||||
final String userL1;
|
||||
final String userL2;
|
||||
final int upperLevel;
|
||||
final int lowerLevel;
|
||||
|
||||
ConstructSummaryRequest({
|
||||
required this.constructs,
|
||||
required this.messages,
|
||||
required this.userL1,
|
||||
required this.userL2,
|
||||
required this.upperLevel,
|
||||
required this.lowerLevel,
|
||||
});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {
|
||||
'constructs': constructs.map((construct) => construct.toJson()).toList(),
|
||||
'msgs': messages,
|
||||
'user_l1': userL1,
|
||||
'user_l2': userL2,
|
||||
'language': userL1,
|
||||
'upper_level': upperLevel,
|
||||
'lower_level': lowerLevel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ConstructSummaryResponse {
|
||||
final ConstructSummary summary;
|
||||
|
||||
ConstructSummaryResponse({required this.summary});
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return {'summary': summary.toJson()};
|
||||
}
|
||||
|
||||
factory ConstructSummaryResponse.fromJson(Map<String, dynamic> json) {
|
||||
return ConstructSummaryResponse(
|
||||
summary: ConstructSummary.fromJson(json['summary']),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConstructRepo {
|
||||
static Future<ConstructSummaryResponse> generateConstructSummary(
|
||||
ConstructSummaryRequest request,
|
||||
) async {
|
||||
final Requests req = Requests(
|
||||
choreoApiKey: Environment.choreoApiKey,
|
||||
accessToken: MatrixState.pangeaController.userController.accessToken,
|
||||
);
|
||||
final Response res = await req.post(
|
||||
url: PApiUrls.constructSummary,
|
||||
body: request.toJson(),
|
||||
);
|
||||
final decodedBody = jsonDecode(utf8.decode(res.bodyBytes));
|
||||
final response = ConstructSummaryResponse.fromJson(decodedBody);
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
class PangeaEventTypes {
|
||||
static const construct = "pangea.construct";
|
||||
static const userSetLemmaInfo = "p.user_lemma_info";
|
||||
static const constructSummary = "pangea.construct_summary";
|
||||
static const userChosenEmoji = "p.emoji";
|
||||
|
||||
static const tokens = "pangea.tokens";
|
||||
|
|
|
|||
|
|
@ -140,7 +140,6 @@ abstract class ClientManager {
|
|||
PangeaEventTypes.activityPlan,
|
||||
PangeaEventTypes.activityRole,
|
||||
PangeaEventTypes.activitySummary,
|
||||
PangeaEventTypes.constructSummary,
|
||||
PangeaEventTypes.activityRoomIds,
|
||||
PangeaEventTypes.coursePlan,
|
||||
PangeaEventTypes.teacherMode,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue