Skip to content

Commit

Permalink
Merge branch 'main' into 818-navigate-menu-and-level-bar
Browse files Browse the repository at this point in the history
  • Loading branch information
ggurdin authored Nov 13, 2024
2 parents f4ba28f + 9f8fef3 commit 4883a96
Show file tree
Hide file tree
Showing 32 changed files with 1,065 additions and 541 deletions.
20 changes: 15 additions & 5 deletions lib/pages/chat/events/message_content.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ import 'package:fluffychat/pages/chat/chat.dart';
import 'package:fluffychat/pages/chat/events/video_player.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/widgets/chat/message_selection_overlay.dart';
import 'package:fluffychat/pangea/widgets/chat/message_token_text_stateful.dart';
import 'package:fluffychat/pangea/widgets/chat/message_toolbar_selection_area.dart';
import 'package:fluffychat/pangea/widgets/chat/overlay_message_text.dart';
import 'package:fluffychat/pangea/widgets/igc/pangea_rich_text.dart';
import 'package:fluffychat/utils/adaptive_bottom_sheet.dart';
import 'package:fluffychat/utils/date_time_extension.dart';
Expand Down Expand Up @@ -306,13 +306,23 @@ class MessageContent extends StatelessWidget {
height: 1.3,
);

if (overlayController != null && pangeaMessageEvent != null) {
return OverlayMessageText(
pangeaMessageEvent: pangeaMessageEvent!,
overlayController: overlayController!,
if (pangeaMessageEvent?.messageDisplayRepresentation?.tokens !=
null) {
return MessageTokenTextStateful(
messageAnalyticsEntry:
controller.pangeaController.getAnalytics.perMessage.get(
pangeaMessageEvent!,
false,
)!,
style: messageTextStyle,
onClick: (token) => controller.showToolbar(pangeaMessageEvent!),
);
}

if (overlayController != null && pangeaMessageEvent != null) {
return overlayController!.messageTokenText;
}

if (immersionMode && pangeaMessageEvent != null) {
return Flexible(
child: PangeaRichText(
Expand Down
23 changes: 6 additions & 17 deletions lib/pages/chat_list/chat_list.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import 'package:fluffychat/config/themes.dart';
import 'package:fluffychat/pages/chat/send_file_dialog.dart';
import 'package:fluffychat/pages/chat_list/chat_list_view.dart';
import 'package:fluffychat/pangea/constants/pangea_room_types.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/extensions/client_extension/client_extension.dart';
import 'package:fluffychat/pangea/extensions/pangea_room_extension/pangea_room_extension.dart';
import 'package:fluffychat/pangea/utils/chat_list_handle_space_tap.dart';
Expand Down Expand Up @@ -1016,7 +1015,7 @@ class ChatListController extends State<ChatList>
}

// #Pangea
await _initPangeaControllers(client);
_initPangeaControllers(client);
// Pangea#
if (!mounted) return;
setState(() {
Expand All @@ -1025,22 +1024,12 @@ class ChatListController extends State<ChatList>
}

// #Pangea
Future<void> _initPangeaControllers(Client client) async {
MatrixState.pangeaController.putAnalytics.initialize();
MatrixState.pangeaController.getAnalytics.initialize();
void _initPangeaControllers(Client client) {
GoogleAnalytics.analyticsUserUpdate(client.userID);
client.migrateAnalyticsRooms();
MatrixState.pangeaController.initControllers();
if (mounted) {
final PangeaController pangeaController = MatrixState.pangeaController;
GoogleAnalytics.analyticsUserUpdate(client.userID);
pangeaController.startChatWithBotIfNotPresent();
await pangeaController.subscriptionController.initialize();
pangeaController.afterSyncAndFirstLoginInitialization(context);
await pangeaController.inviteBotToExistingSpaces();
await pangeaController.setPangeaPushRules();
client.migrateAnalyticsRooms();
} else {
ErrorHandler.logError(
m: "didn't run afterSyncAndFirstLoginInitialization because not mounted",
);
MatrixState.pangeaController.classController.joinCachedSpaceCode(context);
}
}
// Pangea#
Expand Down
3 changes: 2 additions & 1 deletion lib/pangea/controllers/class_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ class ClassController extends BaseController {
);
}

Future<void> checkForClassCodeAndSubscription(BuildContext context) async {
Future<void> joinCachedSpaceCode(BuildContext context) async {
final String? classCode = _pangeaController.pStoreService.read(
PLocalKey.cachedClassCodeToJoin,
isAccountData: false,
Expand All @@ -53,6 +53,7 @@ class ClassController extends BaseController {
context,
classCode,
);

await _pangeaController.pStoreService.delete(
PLocalKey.cachedClassCodeToJoin,
isAccountData: false,
Expand Down
57 changes: 39 additions & 18 deletions lib/pangea/controllers/get_analytics_controller.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import 'dart:math';

import 'package:fluffychat/pangea/constants/class_default_values.dart';
import 'package:fluffychat/pangea/constants/local.key.dart';
import 'package:fluffychat/pangea/controllers/message_analytics_controller.dart';
import 'package:fluffychat/pangea/controllers/pangea_controller.dart';
import 'package:fluffychat/pangea/controllers/put_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/construct_type_enum.dart';
Expand All @@ -19,28 +20,40 @@ import 'package:sentry_flutter/sentry_flutter.dart';
/// A minimized version of AnalyticsController that get the logged in user's analytics
class GetAnalyticsController {
late PangeaController _pangeaController;
late MessageAnalyticsController perMessage;
final List<AnalyticsCacheEntry> _cache = [];
StreamSubscription<AnalyticsUpdate>? _analyticsUpdateSubscription;
StreamController<AnalyticsStreamUpdate> analyticsStream =
StreamController.broadcast();

ConstructListModel constructListModel = ConstructListModel(uses: []);
Completer<void> initCompleter = Completer<void>();

GetAnalyticsController(PangeaController pangeaController) {
_pangeaController = pangeaController;

perMessage = MessageAnalyticsController(
this,
);
}

String? get _l2Code => _pangeaController.languageController.userL2?.langCode;
Client get _client => _pangeaController.matrixState.client;

// the minimum XP required for a given level
double get _minXPForLevel {
return 12.5 * (2 * pow(constructListModel.level - 1, 2) - 1);
int get _minXPForLevel {
return _calculateMinXpForLevel(constructListModel.level);
}

// the minimum XP required for the next level
double get _minXPForNextLevel {
return 12.5 * (2 * pow(constructListModel.level, 2) - 1);
int get _minXPForNextLevel {
return _calculateMinXpForLevel(constructListModel.level + 1);
}

/// Calculates the minimum XP required for a specific level.
int _calculateMinXpForLevel(int level) {
if (level == 1) return 0; // Ensure level 1 starts at 0 XP
return ((100 / 8) * (2 * pow(level - 1, 2))).floor();
}

// the progress within the current level as a percentage (0.0 to 1.0)
Expand All @@ -50,28 +63,36 @@ class GetAnalyticsController {
return progress >= 0 ? progress : 0;
}

void initialize() {
_analyticsUpdateSubscription ??= _pangeaController
.putAnalytics.analyticsUpdateStream.stream
.listen(_onAnalyticsUpdate);

_pangeaController.putAnalytics.lastUpdatedCompleter.future.then((_) {
_getConstructs().then((_) {
constructListModel.updateConstructs([
...(_getConstructsLocal() ?? []),
..._locallyCachedConstructs,
]);
_updateAnalyticsStream();
});
});
Future<void> initialize() async {
if (initCompleter.isCompleted) return;

try {
_analyticsUpdateSubscription ??= _pangeaController
.putAnalytics.analyticsUpdateStream.stream
.listen(_onAnalyticsUpdate);

await _pangeaController.putAnalytics.lastUpdatedCompleter.future;
await _getConstructs();
constructListModel.updateConstructs([
...(_getConstructsLocal() ?? []),
..._locallyCachedConstructs,
]);
_updateAnalyticsStream();
} catch (err, s) {
ErrorHandler.logError(e: err, s: s);
} finally {
if (!initCompleter.isCompleted) initCompleter.complete();
}
}

/// Clear all cached analytics data.
void dispose() {
constructListModel.dispose();
_analyticsUpdateSubscription?.cancel();
_analyticsUpdateSubscription = null;
initCompleter = Completer<void>();
_cache.clear();
// perMessage.dispose();
}

Future<void> _onAnalyticsUpdate(AnalyticsUpdate analyticsUpdate) async {
Expand Down
169 changes: 169 additions & 0 deletions lib/pangea/controllers/message_analytics_controller.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import 'dart:math';

import 'package:fluffychat/pangea/controllers/get_analytics_controller.dart';
import 'package:fluffychat/pangea/enum/activity_type_enum.dart';
import 'package:fluffychat/pangea/matrix_event_wrappers/pangea_message_event.dart';
import 'package:fluffychat/pangea/models/practice_activities.dart/message_activity_request.dart';
import 'package:flutter/foundation.dart';

/// Picks which tokens to do activities on and what types of activities to do
/// Caches result so that we don't have to recompute it
/// Most importantly, we can't do this in the state of a message widget because the state is disposed of and recreated
/// If we decided that the first token should have a hidden word listening, we need to remember that
/// Otherwise, the user might leave the chat, return, and see a different word hidden
class MessageAnalyticsEntry {
final DateTime createdAt = DateTime.now();

late List<TokenWithXP> tokensWithXp;

final PangeaMessageEvent pmEvent;

//
bool isFirstTimeComputing = true;

TokenWithXP? nextActivityToken;
ActivityTypeEnum? nextActivityType;

MessageAnalyticsEntry(this.pmEvent) {
debugPrint('making MessageAnalyticsEntry: ${pmEvent.messageDisplayText}');
if (pmEvent.messageDisplayRepresentation?.tokens == null) {
throw Exception('No tokens in message in MessageAnalyticsEntry');
}
tokensWithXp = pmEvent.messageDisplayRepresentation!.tokens!
.map((token) => TokenWithXP(token: token))
.toList();

computeTargetTypesForMessage();
}

List<TokenWithXP> get tokensThatCanBeHeard =>
tokensWithXp.where((t) => t.token.canBeHeard).toList();

// compute target tokens within async wrapper that adds a 250ms delay
// to avoid blocking the UI thread
Future<void> computeTargetTypesForMessageAsync() async {
await Future.delayed(const Duration(milliseconds: 250));
computeTargetTypesForMessage();
}

void computeTargetTypesForMessage() {
// reset
nextActivityToken = null;
nextActivityType = null;

// compute target types for each token
for (final token in tokensWithXp) {
token.targetTypes = [];

if (!token.token.lemma.saveVocab) {
continue;
}

if (token.daysSinceLastUse < 1) {
continue;
}

if (token.eligibleForActivity(ActivityTypeEnum.wordMeaning) &&
!token.didActivity(ActivityTypeEnum.wordMeaning)) {
token.targetTypes.add(ActivityTypeEnum.wordMeaning);
}

if (token.eligibleForActivity(ActivityTypeEnum.wordFocusListening) &&
!token.didActivity(ActivityTypeEnum.wordFocusListening) &&
tokensThatCanBeHeard.length > 3) {
token.targetTypes.add(ActivityTypeEnum.wordFocusListening);
}

if (token.eligibleForActivity(ActivityTypeEnum.hiddenWordListening) &&
isFirstTimeComputing &&
!token.didActivity(ActivityTypeEnum.hiddenWordListening) &&
!pmEvent.ownMessage) {
token.targetTypes.add(ActivityTypeEnum.hiddenWordListening);
}
}

// from the tokens with hiddenWordListening in targetTypes, pick one at random
final List<int> withListening = tokensWithXp
.asMap()
.entries
.where(
(entry) => entry.value.targetTypes
.contains(ActivityTypeEnum.hiddenWordListening),
)
.map((entry) => entry.key)
.toList();
// randomly pick one entry in the list
if (withListening.isNotEmpty) {
final int randomIndex =
withListening[Random().nextInt(withListening.length)];

nextActivityToken = tokensWithXp[randomIndex];
nextActivityType = ActivityTypeEnum.hiddenWordListening;

// remove from all other tokens
for (int i = 0; i < tokensWithXp.length; i++) {
if (i != randomIndex) {
tokensWithXp[i]
.targetTypes
.remove(ActivityTypeEnum.hiddenWordListening);
}
}
}

// if we didn't find any hiddenWordListening,
// pick the first token that has a target type
nextActivityToken ??=
tokensWithXp.where((t) => t.targetTypes.isNotEmpty).firstOrNull;
nextActivityType ??= nextActivityToken?.targetTypes.firstOrNull;

isFirstTimeComputing = false;
}

bool get shouldHideToken => tokensWithXp.any(
(token) =>
token.targetTypes.contains(ActivityTypeEnum.hiddenWordListening),
);
}

/// computes TokenWithXP for given a pangeaMessageEvent and caches the result, according to the full text of the message
/// listens for analytics updates and updates the cache accordingly
class MessageAnalyticsController {
final GetAnalyticsController getAnalytics;
final Map<String, MessageAnalyticsEntry> _cache = {};

MessageAnalyticsController(this.getAnalytics);

void dispose() {
_cache.clear();
}

// if over 50, remove oldest 5 entries by createdAt
void clean() {
if (_cache.length > 50) {
final sortedEntries = _cache.entries.toList()
..sort((a, b) => a.value.createdAt.compareTo(b.value.createdAt));
for (var i = 0; i < 5; i++) {
_cache.remove(sortedEntries[i].key);
}
}
}

MessageAnalyticsEntry? get(
PangeaMessageEvent pmEvent,
bool refresh,
) {
if (pmEvent.messageDisplayRepresentation?.tokens == null) {
return null;
}

if (_cache.containsKey(pmEvent.messageDisplayText) && !refresh) {
return _cache[pmEvent.messageDisplayText];
}

_cache[pmEvent.messageDisplayText] = MessageAnalyticsEntry(pmEvent);

clean();

return _cache[pmEvent.messageDisplayText];
}
}
Loading

0 comments on commit 4883a96

Please sign in to comment.